aboutsummaryrefslogtreecommitdiffstats
path: root/src/wwwroot/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'src/wwwroot/scripts')
-rw-r--r--src/wwwroot/scripts/api/account-api.ts28
-rw-r--r--src/wwwroot/scripts/api/account-api.types.ts5
-rw-r--r--src/wwwroot/scripts/api/categories-api.ts33
-rw-r--r--src/wwwroot/scripts/api/categories-api.types.ts17
-rw-r--r--src/wwwroot/scripts/api/db-base.ts3
-rw-r--r--src/wwwroot/scripts/api/documents-api.ts24
-rw-r--r--src/wwwroot/scripts/api/order-api.ts64
-rw-r--r--src/wwwroot/scripts/api/order-api.types.ts39
-rw-r--r--src/wwwroot/scripts/api/products-api.ts59
-rw-r--r--src/wwwroot/scripts/api/products-api.types.ts35
-rw-r--r--src/wwwroot/scripts/api/settings-api.ts17
-rw-r--r--src/wwwroot/scripts/api/settings-api.types.ts3
-rw-r--r--src/wwwroot/scripts/back/bestillinger.js321
-rw-r--r--src/wwwroot/scripts/back/dokumenter.js102
-rw-r--r--src/wwwroot/scripts/back/index.js51
-rw-r--r--src/wwwroot/scripts/back/produkter.js671
-rw-r--r--src/wwwroot/scripts/base.js84
-rw-r--r--src/wwwroot/scripts/cart-core.js109
-rw-r--r--src/wwwroot/scripts/components.js48
-rw-r--r--src/wwwroot/scripts/configuration.js41
-rw-r--r--src/wwwroot/scripts/front/handlekurv.js256
-rw-r--r--src/wwwroot/scripts/front/logginn.js92
-rw-r--r--src/wwwroot/scripts/front/produkter.js341
-rw-r--r--src/wwwroot/scripts/front/status.js10
-rw-r--r--src/wwwroot/scripts/grid.ts390
-rw-r--r--src/wwwroot/scripts/i10n.ts95
-rw-r--r--src/wwwroot/scripts/icons.js58
-rw-r--r--src/wwwroot/scripts/messages.js12
-rw-r--r--src/wwwroot/scripts/tabs.js31
-rw-r--r--src/wwwroot/scripts/toaster.js131
-rw-r--r--src/wwwroot/scripts/utilities.js254
-rw-r--r--src/wwwroot/scripts/vendor/cycle.js174
-rw-r--r--src/wwwroot/scripts/vendor/quill.js25
33 files changed, 3623 insertions, 0 deletions
diff --git a/src/wwwroot/scripts/api/account-api.ts b/src/wwwroot/scripts/api/account-api.ts
new file mode 100644
index 0000000..0d8604b
--- /dev/null
+++ b/src/wwwroot/scripts/api/account-api.ts
@@ -0,0 +1,28 @@
+import {LoginPayload} from "./account-api.types"
+
+export function login(payload: LoginPayload, xsrf: string): Promise<Response> {
+ return fetch("/api/account/login", {
+ method: "post",
+ body: JSON.stringify(payload),
+ headers: {
+ "Content-Type": "application/json;charset=utf-8",
+ "XSRF-TOKEN": xsrf
+ }
+ });
+}
+
+export function logout(): Promise<Response> {
+ return fetch("/api/account/logout");
+}
+
+export function updatePassword(newPassword: string): Promise<Response> {
+ return fetch("/api/account/update-password", {
+ method: "post",
+ body: JSON.stringify({
+ newPassword
+ }),
+ headers: {
+ "Content-Type": "application/json;charset=utf-8"
+ }
+ });
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/api/account-api.types.ts b/src/wwwroot/scripts/api/account-api.types.ts
new file mode 100644
index 0000000..3c02441
--- /dev/null
+++ b/src/wwwroot/scripts/api/account-api.types.ts
@@ -0,0 +1,5 @@
+export interface LoginPayload {
+ username: string,
+ password: string,
+ persist: boolean
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/api/categories-api.ts b/src/wwwroot/scripts/api/categories-api.ts
new file mode 100644
index 0000000..1ca1d97
--- /dev/null
+++ b/src/wwwroot/scripts/api/categories-api.ts
@@ -0,0 +1,33 @@
+export function getCategories(withProducts: boolean = false): Promise<Response> {
+ return fetch(withProducts ? "/api/categories/with-products" : "/api/categories");
+}
+
+export function getCategory(categoryId: string): Promise<Response> {
+ return fetch("/api/categories/" + categoryId);
+}
+
+export function createCategory(name: string, disabled: boolean): Promise<Response> {
+ return fetch("/api/categories/create?" + new URLSearchParams({
+ name
+ }));
+}
+
+export function updateCategory(categoryId: string, newName: string): Promise<Response> {
+ return fetch("/api/categories/" + categoryId + "/update?" + new URLSearchParams({
+ newName
+ }));
+}
+
+export function deleteCategory(categoryId: string): Promise<Response> {
+ return fetch("/api/categories/" + categoryId + "/delete", {
+ method: "delete"
+ });
+}
+
+export function enableCategory(categoryId: string): Promise<Response> {
+ return fetch("/api/categories/" + categoryId + "/enable");
+}
+
+export function disableCategory(categoryId: string): Promise<Response> {
+ return fetch("/api/categories/" + categoryId + "/disable");
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/api/categories-api.types.ts b/src/wwwroot/scripts/api/categories-api.types.ts
new file mode 100644
index 0000000..7040cea
--- /dev/null
+++ b/src/wwwroot/scripts/api/categories-api.types.ts
@@ -0,0 +1,17 @@
+import {Base} from "./db-base";
+import {Product} from "./products-api.types";
+
+export interface Category extends Base {
+ name: string,
+ slug: string,
+ visibilityState: CategoryVisibility,
+ disabled: boolean,
+ deleted: boolean,
+ products: Array<Product>
+}
+
+export enum CategoryVisibility {
+ Default = 0,
+ Disabled = 1,
+ Deleted = 2
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/api/db-base.ts b/src/wwwroot/scripts/api/db-base.ts
new file mode 100644
index 0000000..a6593a1
--- /dev/null
+++ b/src/wwwroot/scripts/api/db-base.ts
@@ -0,0 +1,3 @@
+export interface Base {
+ id: string,
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/api/documents-api.ts b/src/wwwroot/scripts/api/documents-api.ts
new file mode 100644
index 0000000..f458f6c
--- /dev/null
+++ b/src/wwwroot/scripts/api/documents-api.ts
@@ -0,0 +1,24 @@
+export function uploadDocumentImages(files: Array<File>): Promise<Response> {
+ if (files.length <= 0) throw new Error("files.length was " + files.length);
+ const data = new FormData();
+ for (const file of files)
+ data.append("files", file);
+
+ return fetch("/api/documents/upload-images", {
+ method: "post",
+ body: data
+ });
+}
+
+export function getDocument(documentType: string) {
+ return fetch("/api/documents/" + documentType);
+}
+
+export function setDocument(documentType: string, content: string) {
+ const fd = new FormData();
+ fd.append("content", content);
+ return fetch("/api/documents/" + documentType, {
+ method: "post",
+ body: fd,
+ });
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/api/order-api.ts b/src/wwwroot/scripts/api/order-api.ts
new file mode 100644
index 0000000..eaacffe
--- /dev/null
+++ b/src/wwwroot/scripts/api/order-api.ts
@@ -0,0 +1,64 @@
+import {SubmitOrderPayload, ValidateOrderPayload} from "./order-api.types";
+
+export function validateOrderProducts(payload: ValidateOrderPayload): Promise<Response> {
+ return fetch("/api/orders/validate-products", {
+ headers: {
+ "Content-Type": "application/json;charset=utf-8"
+ },
+ body: JSON.stringify(payload),
+ method: "post",
+ credentials: "include"
+ });
+}
+
+export function validateOrder(payload: ValidateOrderPayload): Promise<Response> {
+ return fetch("/api/orders/validate", {
+ headers: {
+ "Content-Type": "application/json;charset=utf-8"
+ },
+ body: JSON.stringify(payload),
+ method: "post",
+ credentials: "include"
+ });
+}
+
+export function getOrderDetails(id): Promise<Response> {
+ return fetch("/api/orders/" + id + "/details", {
+ credentials: "include"
+ });
+}
+
+export function captureVippsOrder(id: String): Promise<Response> {
+ return fetch("/api/orders/" + id + "/capture")
+}
+
+export function cancelOrder(id: String): Promise<Response> {
+ return fetch("/api/orders/" + id + "/cancel")
+}
+
+export function refundOrder(id: String): Promise<Response> {
+ return fetch("/api/orders/" + id + "/refund")
+}
+
+export function submitOrder(payload: SubmitOrderPayload): Promise<Response> {
+ return fetch("/api/orders/submit", {
+ headers: {
+ "Content-Type": "application/json;charset=utf-8"
+ },
+ body: JSON.stringify(payload),
+ method: "post",
+ credentials: "include"
+ });
+}
+
+export function getOrders(filter?: string): Promise<Response> {
+ return fetch("/api/orders?filter=" + filter, {
+ credentials: "include"
+ });
+}
+
+export function getOrder(id): Promise<Response> {
+ return fetch("/api/orders/" + id, {
+ credentials: "include"
+ });
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/api/order-api.types.ts b/src/wwwroot/scripts/api/order-api.types.ts
new file mode 100644
index 0000000..8f4ca25
--- /dev/null
+++ b/src/wwwroot/scripts/api/order-api.types.ts
@@ -0,0 +1,39 @@
+import {Base} from "./db-base";
+
+export interface ValidateOrderPayload {
+ products: Array<ProductValidationDto>
+}
+
+export interface Order extends Base {
+ comment: string,
+ paymentType: OrderPaymentType,
+ status: OrderStatus,
+ ContactInfo: ContactInformation,
+ ProductIds: Array<string>
+}
+
+export interface SubmitOrderPayload extends Order {
+}
+
+export interface ContactInformation {
+ name: string,
+ phoneNumber: string,
+ emailaddress: string
+}
+
+export interface ProductValidationDto {
+ id: string,
+ count: number
+}
+
+export enum OrderPaymentType {
+ Vipps = 0,
+ InvoiceByEmail = 1
+}
+
+export enum OrderStatus {
+ InProgress = 0,
+ Completed = 3,
+ Canceled = 1,
+ Failed = 2
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/api/products-api.ts b/src/wwwroot/scripts/api/products-api.ts
new file mode 100644
index 0000000..6069830
--- /dev/null
+++ b/src/wwwroot/scripts/api/products-api.ts
@@ -0,0 +1,59 @@
+import {CreateProductPayload, Product} from "./products-api.types";
+
+export function createProduct(payload: CreateProductPayload): Promise<Response> {
+ return fetch("/api/products/create", {
+ headers: {
+ "Content-Type": "application/json;charset=utf-8"
+ },
+ body: JSON.stringify(payload),
+ method: "post",
+ credentials: "include"
+ });
+}
+
+export function getProduct(id: string): Promise<Response> {
+ return fetch("/api/products/" + id, {
+ method: "get",
+ credentials: "include"
+ });
+}
+
+export function getProducts(): Promise<Response> {
+ return fetch("/api/products", {
+ method: "get",
+ credentials: "include"
+ });
+}
+
+export function uploadProductImages(files: Array<File>): Promise<Response> {
+ if (files.length <= 0) throw new Error("files.length was " + files.length);
+ const data = new FormData();
+ for (const file of files)
+ data.append("files", file);
+
+ return fetch("/api/products/upload-images", {
+ method: "post",
+ body: data
+ });
+}
+
+export function deleteProduct(id: string): Promise<Response> {
+ return fetch("/api/products/" + id + "/delete", {
+ method: "delete",
+ credentials: "include"
+ });
+}
+
+export function updateProduct(data: Product): Promise<Response> {
+ if (!data.id) {
+ throw new Error("data.id was undefined");
+ }
+ return fetch("/api/products/" + data.id + "/update", {
+ method: "post",
+ headers: {
+ "Content-Type": "application/json;charset=utf-8"
+ },
+ body: JSON.stringify(data),
+ credentials: "include"
+ });
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/api/products-api.types.ts b/src/wwwroot/scripts/api/products-api.types.ts
new file mode 100644
index 0000000..f4fdd5e
--- /dev/null
+++ b/src/wwwroot/scripts/api/products-api.types.ts
@@ -0,0 +1,35 @@
+import {Base} from "./db-base";
+import {Category} from "./categories-api.types";
+
+export interface Product extends Base {
+ name: string,
+ description: string,
+ price: number,
+ priceSuffix: PriceSuffix,
+ visibilityState: ProductVisibility,
+ category: Category,
+ images: Array<Image>,
+ slug: string,
+ disabled: boolean,
+ deleted: boolean
+}
+
+export interface CreateProductPayload extends Product {
+}
+
+export interface Image {
+ order: number,
+ fileName: string
+}
+
+export enum PriceSuffix {
+ Money = 0,
+ Kilos = 1,
+ Per = 2
+}
+
+export enum ProductVisibility {
+ Default = 0,
+ Disabled = 1,
+ Deleted = 2
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/api/settings-api.ts b/src/wwwroot/scripts/api/settings-api.ts
new file mode 100644
index 0000000..7f7e482
--- /dev/null
+++ b/src/wwwroot/scripts/api/settings-api.ts
@@ -0,0 +1,17 @@
+export function setOrderEmailList(payload: string[]): Promise<Response> {
+ return fetch("/api/settings/order-emails", {
+ headers: {
+ "Content-Type": "application/json;charset=utf-8"
+ },
+ body: JSON.stringify(payload),
+ method: "post",
+ credentials: "include"
+ });
+}
+
+export function getOrderEmailList(): Promise<Response> {
+ return fetch("/api/settings/order-emails", {
+ method: "get",
+ credentials: "include"
+ });
+}
diff --git a/src/wwwroot/scripts/api/settings-api.types.ts b/src/wwwroot/scripts/api/settings-api.types.ts
new file mode 100644
index 0000000..fe9864a
--- /dev/null
+++ b/src/wwwroot/scripts/api/settings-api.types.ts
@@ -0,0 +1,3 @@
+export interface SettingsApiTypes {
+
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/back/bestillinger.js b/src/wwwroot/scripts/back/bestillinger.js
new file mode 100644
index 0000000..17b56d5
--- /dev/null
+++ b/src/wwwroot/scripts/back/bestillinger.js
@@ -0,0 +1,321 @@
+import Grid from "../grid";
+import {strings} from "../i10n";
+import {$, doc, pageInit, toaster} from "../base";
+import {eyeIcon} from "../icons";
+import {utilites} from "../utilities";
+import {messages} from "../messages";
+import {cancelOrder, captureVippsOrder, getOrderDetails, getOrders, refundOrder} from "../api/order-api";
+import Modal from "bootstrap/js/src/modal";
+
+if (location.pathname.startsWith("/kontoret/bestillinger")) {
+ const ordersLoader = $("#orders-loader");
+ const ordersWrapper = $("#orders-wrapper");
+ const orderInfoModalEl = $("#order-info-modal");
+ const orderInfoModal = new Modal(orderInfoModalEl);
+
+ const grid = new Grid({
+ search: {
+ dataIds: ["orderReference", ["contactInfo", "name"], ["contactInfo", "emailAddress"], ["contactInfo", "phoneNumber"]],
+ },
+ strings: {
+ search: strings.languageSpesific.search,
+ nextPage: strings.languageSpesific.next_page,
+ previousPage: strings.languageSpesific.previous_page,
+ },
+ debug: location.href.includes("localhost"),
+ classes: {
+ table: "table table-bordered mt-3",
+ thead: "table-primary",
+ },
+ columns: [
+ {
+ dataId: "orderReference",
+ minWidth: "150px",
+ columnName: "Referanse",
+ className: "btn-link cursor-pointer",
+ click: (row) => openViewOrderModal(row),
+ },
+ {
+ columnName: "Navn",
+ dataId: ["contactInfo", "name"],
+ minWidth: "200px",
+ },
+ {
+ columnName: "E-postadresse",
+ dataId: ["contactInfo", "emailAddress"],
+ minWidth: "200px",
+ },
+ {
+ columnName: "Telefonnummer",
+ dataId: ["contactInfo", "phoneNumber"],
+ minWidth: "150px",
+ },
+ {
+ dataId: "created",
+ minWidth: "175px",
+ cellValue: (row) => utilites.toReadableDateString(new Date(row.created)),
+ columnName: "Dato",
+ },
+ {
+ minWidth: "150px",
+ columnName: "Status",
+ cellValue: (row) => {
+ const status = doc.createElement("span");
+ switch (row.status) {
+ case 0: {
+ status.innerText = "Pågående";
+ break;
+ }
+ case 1: {
+ status.innerText = "Kansellert";
+ break;
+ }
+ case 2: {
+ status.innerText = "Feilet";
+ status.classList.add("text-danger");
+ break;
+ }
+ case 3: {
+ status.innerText = "Fullført";
+ status.classList.add("text-success");
+ break;
+ }
+ case 4: {
+ status.innerText = "Venter på faktura";
+ break;
+ }
+ case 5: {
+ status.innerText = "Venter på vipps";
+ break;
+ }
+ }
+ return status;
+ },
+ },
+ {
+ columnName: "",
+ width: "40px",
+ minWidth: "40px",
+ cellValue: (row) => {
+ const viewOrder = doc.createElement("button");
+ viewOrder.className = "btn btn-link text-info shadow-none";
+ viewOrder.title = strings.languageSpesific.view + ` "${row.orderReference}"`;
+ viewOrder.innerHTML = eyeIcon();
+ viewOrder.onclick = () => openViewOrderModal(row);
+ return viewOrder;
+ },
+ },
+ ],
+ });
+
+ if (location.pathname.startsWith("/kontoret/bestillinger")) {
+ pageInit(() => {
+ renderProductsView();
+ });
+ }
+
+ function openViewOrderModal({id, orderReference}) {
+ orderInfoModalEl.querySelector(".modal-title").innerText = orderReference;
+ $("#loader").style.display = "";
+ $("#loaded").style.display = "none";
+
+ orderInfoModal.show();
+
+ getOrderDetails(id).then(res => {
+ if (res.ok) {
+ res.json().then(order => {
+ order = utilites.resolveReferences(order);
+ $("#contact-info-name").innerText = order.contactInformation.name;
+ $("#contact-info-emailAddress").innerHTML = `<a href='mailto:${order.contactInformation.emailAddress}'>${order.contactInformation.emailAddress}</a>`;
+ $("#contact-info-phoneNumber").innerHTML = `<a href='tel:${order.contactInformation.phoneNumber}'>${order.contactInformation.phoneNumber}</a>`;
+ $("#order-reference").innerText = order.orderReference;
+ $("#order-date").innerText = new Date(order.orderDate).toLocaleDateString();
+ $("#order-payment-type").innerText = utilites.getOrderPaymentName(order.paymentType);
+ $("#order-status").innerText = utilites.getOrderStatusName(order.status);
+ if (order.comment && order.comment !== "") {
+ $("#order-comment").innerText = order.comment;
+ $("#order-comment").style.display = "inline-block";
+ } else {
+ $("#order-comment").style.display = "none";
+ }
+
+ if (order.paymentType === 0) {
+ if ([
+ "SALE",
+ "RESERVED",
+ ].includes(order.vippsTransactionStatus)) {
+ $("#vipps-order-refund").classList.remove("d-none");
+ $("#vipps-order-refund").onclick = () => {
+ if (confirm("Er du sikker på at du vil refundere ordren " + order.orderReference)) {
+ $("#vipps-order-refund").classList.add("loading");
+ refundOrder(order.id).then(res => {
+ if (res.ok) {
+ toaster.success("Refundert", "Ordren er refundert, og vipps har startet refundering");
+ } else {
+ console.error("Received non-successful status code when submitting refund order command");
+ toaster.error("En feil oppstod", "Prøv å refundere i vipps portalen");
+ }
+ $("#vipps-order-refund").classList.remove("loading");
+ }).catch(err => {
+ $("#vipps-order-refund").classList.remove("loading");
+
+ console.error("refund order NetworkRequest failed");
+ toaster.error("En uventet feil oppstod");
+ console.error(err);
+ });
+ }
+ };
+ }
+
+ if ([
+ "SALE",
+ "RESERVED",
+ ].includes(order.vippsTransactionStatus)) {
+ $("#vipps-order-capture").classList.remove("d-none");
+ $("#vipps-order-capture").onclick = () => {
+ if (confirm("Er du sikker på at du vil fullføre ordren " + order.orderReference)) {
+ $("#vipps-order-capture").classList.add("loading");
+ captureVippsOrder(order.id).then(res => {
+ if (res.ok) {
+ toaster.success("Fullført", "Ordren er fullført, og vipps har registrert transaksjonen");
+ } else {
+ console.error("Received non-successful status code when submitting fulfill order command");
+ toaster.error("En feil oppstod", "Prøv å fullføre i vipps portalen");
+ }
+ $("#vipps-order-capture").classList.remove("loading");
+ }).catch(err => {
+ $("#vipps-order-capture").classList.remove("loading");
+ console.error("fulfill order NetworkRequest failed");
+ toaster.error("En uventet feil oppstod");
+ console.error(err);
+ });
+ }
+ };
+ }
+
+ if ([
+ "SALE",
+ "RESERVED",
+ ].includes(order.vippsTransactionStatus)) {
+ $("#vipps-order-cancel").classList.remove("d-none");
+ $("#vipps-order-cancel").onclick = () => {
+ if (confirm("Er du sikker på at du vil kansellere ordren " + order.orderReference)) {
+ $("#vipps-order-cancel").classList.add("loading");
+ cancelOrder(order.id).then(res => {
+ if (res.ok) {
+ toaster.success("Kansellert", "Ordren er kansellert");
+ } else {
+ console.error("Received non-successful status code when submitting cancel order command");
+ toaster.error("En feil oppstod", "Prøv å kansellere i vipps portalen");
+ }
+ $("#vipps-order-cancel").classList.remove("loading");
+ }).catch(err => {
+ $("#vipps-order-cancel").classList.remove("loading");
+ console.error("cancel order NetworkRequest failed");
+ toaster.error("En uventet feil oppstod");
+ console.error(err);
+ });
+ }
+ };
+ }
+
+
+ switch (order.vippsTransactionStatus) {
+ case "SALE":
+ $("#vipps-status").innerText = "Salg (fullført og overført)";
+ break;
+ case "RESERVED":
+ $("#vipps-status").innerText = "Reservert";
+ break;
+ case "CANCELLED":
+ $("#vipps-status").innerText = "Kansellert";
+ break;
+ case "REJECTED":
+ $("#vipps-status").innerText = "Avslått";
+ break;
+ case "SALE_FAILED":
+ $("#vipps-status").innerText = "Salg feilet";
+ break;
+ case "AUTO_CANCEL":
+ $("#vipps-status").innerText = "Automatisk kansellering";
+ break;
+ case "RESERVE_FAILED":
+ $("#vipps-status").innerText = "Reservering feilet";
+ break;
+ }
+ $("#vipps-link").innerHTML = `<a href='https://portal.vipps.no/61861/transactions/${order.vippsId}' target="_blank">Åpne bestillingen i Vipps-portalen</a>`;
+ $("#vipps-section").classList.remove("d-none");
+ } else {
+ $("#vipps-section").classList.add("d-none");
+ }
+
+ orderInfoModalEl.querySelector(".modal-body").style.display = "";
+
+ const productsBody = orderInfoModalEl.querySelector("tbody");
+ productsBody.innerHTML = "";
+ let productIndex = 1;
+ let totalPrice = 0;
+ for (const product of order.products) {
+ const rowItem = doc.createElement("tr");
+ const nrCell = doc.createElement("td");
+ nrCell.innerText = productIndex.toString();
+ const nameCell = doc.createElement("td");
+ nameCell.innerText = product.name;
+ const countCell = doc.createElement("td");
+ countCell.innerText = product.count;
+ const priceCell = doc.createElement("td");
+ const totalSum = product.payedPrice * product.count;
+ priceCell.innerText = `${product.payedPrice} (totalt: ${totalSum})`;
+
+ rowItem.appendChild(nrCell);
+ rowItem.appendChild(nameCell);
+ rowItem.appendChild(countCell);
+ rowItem.appendChild(priceCell);
+ productsBody.appendChild(rowItem);
+ productIndex++;
+ totalPrice += totalSum;
+ }
+ $("#order-total").innerText = totalPrice;
+ $("#loader").style.display = "none";
+ $("#loaded").style.display = "";
+ orderInfoModal.show();
+ });
+ } else {
+ toaster.error("En feil oppstod", "Kunne ikke hente ordren, prøv igjen snart!");
+ }
+ }).catch(error => {
+ console.error(error);
+ });
+ }
+
+ function renderProductsView() {
+ ordersWrapper.innerHTML = "";
+ ordersWrapper.classList.add("d-none");
+ ordersLoader.classList.remove("d-none");
+
+ getOrders("not-cancelled").then(res => {
+ if (res.ok) {
+ res.json().then(products => {
+ grid.render(ordersWrapper);
+ products = utilites.resolveReferences(products);
+ grid.refresh(products);
+ const queryOrderRef = new URL(location.href).searchParams.get("order");
+ if (queryOrderRef) {
+ const product = products.find(c => c.orderReference === queryOrderRef);
+ openViewOrderModal(product);
+ }
+ });
+ ordersWrapper.classList.remove("d-none");
+ ordersLoader.classList.add("d-none");
+ } else {
+ utilites.handleError(res, {
+ title: strings.languageSpesific.could_not_retrieve_orders,
+ message: strings.languageSpesific.try_again_soon,
+ });
+ }
+ }).catch(error => {
+ console.error(error);
+ toaster.errorObj(messages.networkRequestFailed);
+ });
+ }
+}
diff --git a/src/wwwroot/scripts/back/dokumenter.js b/src/wwwroot/scripts/back/dokumenter.js
new file mode 100644
index 0000000..c92bdbf
--- /dev/null
+++ b/src/wwwroot/scripts/back/dokumenter.js
@@ -0,0 +1,102 @@
+import {$, pageInit, toaster} from "../base";
+import Quill from "../vendor/quill";
+import {uploadDocumentImages, getDocument, setDocument} from "../api/documents-api";
+import {configuration} from "../configuration";
+import {utilites} from "../utilities";
+import {strings} from "../i10n";
+
+if (location.pathname.startsWith("/kontoret/dokumenter")) {
+ const documentSelect = $("#document-selector");
+ const publishButton = $("#publish-button");
+ const toolbarOptions = [["link", "image"],
+ ["bold", "italic", "underline", "strike"],
+ ["blockquote", "code-block"],
+ [{"header": 1}, {"header": 2}],
+ [{"list": "ordered"}, {"list": "bullet"}],
+ [{"script": "sub"}, {"script": "super"}],
+ [{"indent": "-1"}, {"indent": "+1"}],
+ [{"direction": "rtl"}],
+ [{"size": ["small", false, "large", "huge"]}],
+ [{"header": [1, 2, 3, 4, 5, 6, false]}],
+ [{"color": []}, {"background": []}],
+ [{"font": []}],
+ [{"align": []}],
+ ["clean"]];
+
+
+ pageInit(() => {
+ documentSelect.onchange = () => setDocumentContent();
+ publishButton.onclick = () => publishDocument();
+ const editor = new Quill("#editor", {
+ theme: "snow",
+ modules: {
+ toolbar: toolbarOptions,
+ blotFormatter: true,
+ imageDrop: true,
+ imageUploader: {
+ upload: (file) => new Promise(((resolve, reject) => {
+ uploadDocumentImages([file]).then(res => {
+ if (res.ok) {
+ res.json().then(fileNames => {
+ fileNames = utilites.resolveReferences(fileNames);
+ resolve(configuration.paths.documents + fileNames[0]);
+ });
+ } else {
+ reject(res);
+ }
+ }).catch(error => {
+ reject(error);
+ });
+ })),
+ },
+ },
+ });
+
+ function publishDocument() {
+ const html = editor.container.querySelector(".ql-editor").innerHTML;
+ if (!html || html === "<p><br></p>") return;
+ setDocument(documentSelect.value, html).then(res => {
+ if (res.ok) {
+ toaster.success("Dokumentet er publisert");
+ } else {
+ utilites.handleError(res, {
+ title: "Kunne ikke publisere dokumentet",
+ message: "Prøv igjen senere",
+ });
+ }
+ }).catch(error => {
+ console.error(error);
+ toaster.errorObj({
+ title: strings.languageSpesific.could_not_reach_server,
+ message: strings.languageSpesific.try_again_soon,
+ });
+ });
+ }
+
+ function setDocumentContent() {
+ getDocument(documentSelect.value).then(res => {
+ if (res.ok) {
+ res.text().then(content => {
+ editor.setText("");
+ if (content) {
+ editor.clipboard.dangerouslyPasteHTML(0, content);
+ }
+ });
+ } else {
+ utilites.handleError(res, {
+ title: strings.languageSpesific.an_error_occured,
+ message: strings.languageSpesific.try_again_soon,
+ });
+ }
+ }).catch(error => {
+ console.error(error);
+ toaster.errorObj({
+ title: strings.languageSpesific.could_not_reach_server,
+ message: strings.languageSpesific.try_again_soon,
+ });
+ });
+ }
+
+ setDocumentContent();
+ });
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/back/index.js b/src/wwwroot/scripts/back/index.js
new file mode 100644
index 0000000..5b71a8d
--- /dev/null
+++ b/src/wwwroot/scripts/back/index.js
@@ -0,0 +1,51 @@
+import {$, toaster} from "../base";
+import {logout, updatePassword} from "../api/account-api";
+import {Modal} from "bootstrap";
+import {utilites} from "../utilities";
+import {messages} from "../messages";
+import {strings} from "../i10n.ts";
+
+const updatePasswordModalElement = $("#update-password-modal");
+const updatePasswordModal = new Modal(updatePasswordModalElement);
+const logoutButton = $(".logout-btn");
+const newPasswordInput = $("#input-new-password");
+const submitNewPasswordFormButton = $("#submit-new-password-form");
+
+logoutButton.addEventListener("click", (e) => {
+ e.preventDefault();
+ if (confirm(strings.languageSpesific.are_you_sure)) {
+ logout().then((res) => {
+ if (res.status === 200) {
+ setTimeout(function () {
+ location.href = "/";
+ }, 500);
+ }
+ });
+ }
+});
+
+function initSetpasswordModal() {
+ newPasswordInput.value = "";
+ submitNewPasswordFormButton.addEventListener("click", () => {
+ if (newPasswordInput.value.length < 6) return;
+ submitNewPasswordFormButton.querySelector(".spinner-border").classList.remove("d-none");
+ submitNewPasswordFormButton.classList.add("disabled");
+ updatePassword(newPasswordInput.value).then(res => {
+ if (res.ok) {
+ toaster.success(strings.languageSpesific.new_password_is_applied);
+ newPasswordInput.value = "";
+ updatePasswordModal.hide();
+ } else {
+ utilites.handleError(res, messages.unknownError);
+ }
+ submitNewPasswordFormButton.querySelector(".spinner-border").classList.add("d-none");
+ submitNewPasswordFormButton.classList.remove("disabled");
+ });
+ });
+}
+
+$(".open-update-password-modal").addEventListener("click", (e) => {
+ e.preventDefault();
+ initSetpasswordModal();
+ updatePasswordModal.show();
+}); \ No newline at end of file
diff --git a/src/wwwroot/scripts/back/produkter.js b/src/wwwroot/scripts/back/produkter.js
new file mode 100644
index 0000000..ab8ecf6
--- /dev/null
+++ b/src/wwwroot/scripts/back/produkter.js
@@ -0,0 +1,671 @@
+import {$, $$, doc, toaster} from "../base";
+import {configuration} from "../configuration";
+import {utilites} from "../utilities";
+import {createCategory, deleteCategory, disableCategory, enableCategory, getCategories} from "../api/categories-api";
+import {createProduct, deleteProduct, getProducts, updateProduct, uploadProductImages} from "../api/products-api";
+import Modal from "bootstrap/js/dist/modal";
+import Grid from "../grid";
+import {messages} from "../messages";
+import {eyeIcon, pencilSquareIcon, plusIcon, threeDotsVerticalIcon, trashIcon} from "../icons";
+import {strings} from "../i10n.ts";
+
+let shouldReloadProductsView;
+const isProductsPage = location.pathname.startsWith("/kontoret/produkter");
+const inputImages = $("#input-images-row");
+const productsWrapper = $("#products");
+const productsLoader = $("#products-loader");
+const productForm = $("#product-form");
+const productCategoriesPickerElement = $("#product-category-picker-wrapper #picker");
+const productCategoriesPickerLoadingElement = $("#product-category-picker-wrapper #loader");
+const productModalElement = $("#product-modal");
+const submitProductFormButton = $("#submit-product-form");
+const submitProductFormButtonSpinner = $("#submit-product-form .spinner-border");
+const openProductModalButton = $("#open-product-modal");
+const productModalTitle = $("#product-modal-title");
+const productModal = isProductsPage ? new Modal(productModalElement, {
+ backdrop: "static",
+}) : undefined;
+
+const newCategoryForm = $("#new-category-form");
+const newCategoryName = $("#new-category-name");
+const categoryModalElement = $("#categories-modal");
+const categoryModal = isProductsPage ? new Modal(categoryModalElement) : undefined;
+const categoryListElement = $("#categories-modal #list-wrapper");
+const openCategoriesModalButton = $("#open-categories-modal");
+const categoryListLoadingElement = $("#categories-modal #loading-wrapper");
+
+if (isProductsPage) {
+ renderProductsView();
+ sessionStorage.removeItem(configuration.storageKeys.productForm.imageUrls);
+ openProductModalButton.addEventListener("click", () => openProductModal(undefined, undefined));
+ openCategoriesModalButton.addEventListener("click", openCategoriesModal);
+}
+
+/*
+ PRODUCTS
+*/
+
+const grid = new Grid({
+ search: {
+ dataIds: ["name", ["category", "name"]],
+ },
+ strings: {
+ search: strings.languageSpesific.search,
+ nextPage: strings.languageSpesific.next_page,
+ previousPage: strings.languageSpesific.previous_page,
+ },
+ classes: {
+ table: "table table-bordered mt-3",
+ thead: "table-primary",
+ },
+ columns: [
+ {
+ dataId: "name",
+ minWidth: "250px",
+ columnName: "Navn",
+ },
+ {
+ columnName: "Beskrivelse",
+ dataId: "description",
+ maxWidth: "250px",
+ minWidth: "250px",
+ width: "250px",
+ },
+ {
+ minWidth: "250px",
+ dataId: ["category", "name"],
+ columnName: "Kategori",
+ },
+ {
+ minWidth: "100px",
+ columnName: "Pris",
+ cellValue: (row) => row.price + row.readablePriceSuffix,
+ },
+ {
+ columnName: "",
+ width: "80px",
+ minWidth: "80px",
+ cellValue: (row) => {
+ const group = doc.createElement("div");
+ group.className = "btn-group";
+ const deleteProductButton = doc.createElement("button");
+ deleteProductButton.className = "btn btn-link text-danger shadow-none p-0 ps-3";
+ deleteProductButton.title = strings.languageSpesific.delete + ` "${row.name}"`;
+ deleteProductButton.innerHTML = trashIcon("22", "22");
+ deleteProductButton.onclick = () => removeProduct(row.id, row.name);
+ const editProductButton = doc.createElement("button");
+ editProductButton.className = "btn btn-link text-info shadow-none p-0";
+ editProductButton.title = strings.languageSpesific.edit + ` "${row.name}"`;
+ editProductButton.innerHTML = pencilSquareIcon();
+ editProductButton.onclick = () => openProductModal(row);
+ group.appendChild(editProductButton);
+ group.appendChild(deleteProductButton);
+ return group;
+ },
+ },
+ ],
+});
+
+function renderProductsView() {
+ productsWrapper.innerHTML = "";
+ productsWrapper.classList.add("d-none");
+ productsLoader.classList.remove("d-none");
+
+ getProducts().then(res => {
+ if (res.ok) {
+ res.json().then(products => {
+ grid.render(productsWrapper);
+ products = utilites.resolveReferences(products);
+ grid.refresh(products);
+ });
+ productsWrapper.classList.remove("d-none");
+ productsLoader.classList.add("d-none");
+ } else {
+ utilites.handleError(res, {
+ title: strings.languageSpesific.could_not_retrieve_products,
+ message: strings.languageSpesific.try_again_soon,
+ });
+ }
+ }).catch(error => {
+ console.error(error);
+ toaster.errorObj(messages.networkRequestFailed);
+ });
+}
+
+function removeProduct(id, name) {
+ if (!name || !id) return;
+ if (confirm(`${strings.languageSpesific.are_you_sure_you_want_to_delete} ${name}?`)) {
+ deleteProduct(id).then(res => {
+ if (res.ok) {
+ toaster.success(`${name} ${strings.languageSpesific.is_deleted_LC}`);
+ grid.removeByID(id);
+ } else {
+ utilites.handleError(res, {
+ title: strings.languageSpesific.could_not_delete_product,
+ message: strings.languageSpesific.try_again_soon,
+ });
+ }
+ }).catch(error => {
+ console.error(error);
+ toaster.errorObj(messages.networkRequestFailed);
+ });
+ }
+}
+
+
+function uploadFiles() {
+ const input = doc.createElement("input");
+ const maxFiles = 5;
+ const maxFileSize = 2 * 1024 * 1024;
+ input.type = "file";
+ input.multiple = true;
+ input.accept = "image/png,image/jpeg";
+ input.onchange = (e) => {
+ productForm.classList.add("loading");
+ const files = e.target.files;
+
+ if (files.length > maxFiles) {
+ toaster.error(strings.languageSpesific.too_many_files, strings.languageSpesific.max_five_files_at_a_time);
+ productForm.classList.remove("loading");
+ return;
+ }
+
+ for (const file of files) {
+ if (!input.accept.split(",").includes(file.type)) {
+ toaster.error(strings.languageSpesific.invalid_file, file.name + " " + strings.languageSpesific.has_invalid_file_format_LC);
+ productForm.classList.remove("loading");
+ return;
+ }
+ if (file.size > maxFileSize) {
+ toaster.error(strings.languageSpesific.too_big_file, file.name + " " + strings.languageSpesific.is_too_big_LC);
+ productForm.classList.remove("loading");
+ return;
+ }
+ }
+
+ uploadProductImages(files).then(res => {
+ if (res.ok) {
+ res.json().then(fileNameArray => {
+ fileNameArray = utilites.resolveReferences(fileNameArray);
+ const sessionStorageImages = utilites.getSessionStorageJSON(configuration.storageKeys.productForm.imageUrls) ?? [];
+ const newImages = [...sessionStorageImages];
+ for (let i = 0; i < fileNameArray.length; i++) {
+ newImages.push({
+ order: i,
+ fileName: fileNameArray[i],
+ });
+ }
+ utilites.setSessionStorageJSON(configuration.storageKeys.productForm.imageUrls, newImages);
+ renderProductFormImages(() => {
+ productForm.classList.remove("loading");
+ if (inputImages.childNodes.length > 3) {
+ inputImages.scrollLeft = inputImages.scrollWidth;
+ }
+ });
+ });
+ } else {
+ utilites.handleError(res, {
+ title: strings.languageSpesific.could_not_upload + " " + (files.length === 1
+ ? strings.languageSpesific.the_image.toLocaleLowerCase()
+ : strings.languageSpesific.the_images.toLocaleLowerCase()),
+ message: strings.languageSpesific.try_again_soon,
+ });
+ productForm.classList.remove("loading");
+ }
+ }).catch(error => {
+ console.error(error);
+ toaster.errorObj(messages.networkRequestFailed);
+ productForm.classList.remove("loading");
+ });
+ };
+ input.click();
+}
+
+function renderProductFormImages(cb) {
+ inputImages.innerHTML = "";
+ inputImages.appendChild(getAddImageCardButton());
+ let images = utilites.getSessionStorageJSON(configuration.storageKeys.productForm.imageUrls);
+ if (utilites.array.isEmpty(images)) return;
+ images = images.sort(img => img.order);
+ for (const image of images) {
+ inputImages.appendChild(generateProductFormImageColumn(image));
+ }
+ // drag to scroll
+ let pos = {top: 0, left: 0, x: 0, y: 0};
+ let mouseIsDown;
+ inputImages.addEventListener("mousemove", (e) => {
+ if (mouseIsDown) {
+ const dx = e.clientX - pos.x;
+ // scroll
+ inputImages.scrollLeft = pos.left - dx;
+ }
+ });
+ inputImages.addEventListener("mousedown", (e) => {
+ mouseIsDown = true;
+ inputImages.style.cursor = "grabbing";
+ inputImages.style.userSelect = "none";
+ pos = {
+ // current scroll
+ left: inputImages.scrollLeft,
+ top: inputImages.scrollTop,
+ // current mouse position
+ x: e.clientX,
+ y: e.clientY,
+ };
+ });
+ inputImages.addEventListener("mouseup", () => {
+ mouseIsDown = false;
+ inputImages.style.removeProperty("cursor");
+ inputImages.style.removeProperty("user-select");
+ });
+
+ if (typeof cb === "function") cb();
+}
+
+
+function getAddImageCardButton() {
+ const wrapper = doc.createElement("div");
+ wrapper.className = "col mw-100px";
+ const button = doc.createElement("div");
+ button.type = "button";
+ button.onclick = () => uploadFiles();
+ button.className = "btn btn-light p-3 d-flex align-items-center justify-content-center h-100 border-primary text-primary";
+ button.innerHTML = plusIcon("32", "32");
+ wrapper.appendChild(button);
+ return wrapper;
+}
+
+function deleteImageFromSessionStorage(image) {
+ const oldImages = utilites.getSessionStorageJSON(configuration.storageKeys.productForm.imageUrls);
+ const newImages = [];
+ for (const oldImage of oldImages)
+ if (oldImage.fileName !== image.fileName)
+ newImages.push(oldImage);
+ utilites.setSessionStorageJSON(configuration.storageKeys.productForm.imageUrls, newImages);
+ renderProductFormImages();
+}
+
+function generateProductFormImageColumn(image) {
+ const wrapper = doc.createElement("div");
+ wrapper.className = "col-3 product-form-image-thumbnail";
+
+ const card = doc.createElement("div");
+ card.className = "card";
+
+ const img = doc.createElement("img");
+ img.src = configuration.paths.products + image.fileName;
+ img.className = "card-img";
+ img.alt = strings.languageSpesific.picture_of_the_product;
+
+ const cardOverlay = doc.createElement("div");
+ cardOverlay.className = "card-img-overlay";
+
+ const dropdownWrapper = doc.createElement("div");
+ dropdownWrapper.className = "dropleft";
+
+ const contextMenuButton = doc.createElement("div");
+ contextMenuButton.className = "btn btn-light float-end context-menu-button";
+ contextMenuButton.setAttribute("data-bs-toggle", "dropdown");
+ contextMenuButton.setAttribute("aria-expanded", "false");
+ contextMenuButton.type = "button";
+ contextMenuButton.innerHTML = threeDotsVerticalIcon();
+
+ const dropdownMenu = doc.createElement("ul");
+ dropdownMenu.className = "dropdown-menu";
+
+ const deleteMenuItem = doc.createElement("li");
+ deleteMenuItem.innerText = `${strings.languageSpesific.delete} ${strings.languageSpesific.the_image.toLocaleLowerCase()}`;
+ deleteMenuItem.onclick = () => deleteImageFromSessionStorage(image);
+ deleteMenuItem.className = "text-danger dropdown-item";
+
+ dropdownMenu.appendChild(deleteMenuItem);
+ dropdownWrapper.appendChild(contextMenuButton);
+ dropdownWrapper.appendChild(dropdownMenu);
+ cardOverlay.appendChild(dropdownWrapper);
+ card.appendChild(img);
+ card.appendChild(cardOverlay);
+ wrapper.appendChild(card);
+ return wrapper;
+}
+
+function generateCategoryPickerItem(category, initialCategoryId = undefined) {
+ const wrapper = doc.createElement("div");
+ wrapper.className = "form-check form-check-inline";
+ const radio = doc.createElement("input");
+ radio.type = "radio";
+ radio.name = "category";
+ radio.className = "form-check-input";
+ radio.value = category.id;
+ radio.id = category.id;
+ if (initialCategoryId !== undefined && category.id === initialCategoryId) radio.checked = true;
+ const label = doc.createElement("label");
+ label.className = "form-check-label cursor-pointer";
+ label.innerHTML = category.disabled ? ("<span class='text-decoration-line-through'>" + category.name + "</span>") : category.name;
+ label.htmlFor = category.id;
+ wrapper.appendChild(radio);
+ wrapper.appendChild(label);
+ return wrapper;
+}
+
+function renderCategoriesPicker(initialCategoryId = undefined) {
+ productCategoriesPickerElement.innerHTML = "";
+ getCategories().then(res => {
+ if (res.ok) {
+ res.json().then(data => {
+ let i = 0;
+ data = utilites.resolveReferences(data);
+ data.forEach(category => {
+ i++;
+ if (i % 3 === 1) productCategoriesPickerElement.appendChild(doc.createElement("br"));
+ if (initialCategoryId !== undefined)
+ productCategoriesPickerElement.appendChild(generateCategoryPickerItem(category, initialCategoryId));
+ else
+ productCategoriesPickerElement.appendChild(generateCategoryPickerItem(category));
+ });
+ productCategoriesPickerLoadingElement.classList.add("d-none");
+ productCategoriesPickerElement.classList.remove("d-none");
+ });
+ } else {
+ utilites.handleError(res, {
+ title: strings.languageSpesific.could_not_retrieve_categories,
+ message: strings.languageSpesific.try_again_soon,
+ });
+ }
+ }).catch((error) => {
+ console.error(error);
+ toaster.errorObj(messages.networkRequestFailed);
+ });
+}
+
+function submitProductForm() {
+ const id = productModalElement.dataset.id;
+ const isEditing = id !== "" && productModalElement.dataset.isEditing === "true";
+ const payload = {
+ name: $("#input-name").value,
+ price: $("#input-price").value,
+ priceSuffix: parseInt($("#input-price-suffix").value),
+ description: $("#input-description").value,
+ count: parseInt($("#input-count").value),
+ showOnFrontpage: $("input[name='show-on-frontpage']:checked")?.checked ?? false,
+ category: {
+ id: $("input[name='category']:checked").value,
+ },
+ images: [],
+ };
+
+ if (!payload.count && payload.count !== 0) payload.count = -1;
+
+ const images = utilites.getSessionStorageJSON(configuration.storageKeys.productForm.imageUrls);
+ if (images !== undefined) {
+ if (typeof images[0] !== "object") {
+ for (let i = 0; i < images.length; i++) {
+ payload.images.push({
+ order: i,
+ fileName: images[i],
+ });
+ }
+ } else {
+ payload.images = images;
+ }
+ }
+
+ if (isEditing) payload.id = id;
+ if (!payload.name || !payload.price || !payload.category.id) {
+ toaster.error(strings.languageSpesific.invalid_form);
+ return;
+ }
+
+ if (payload.price.substring(payload.price.lastIndexOf(".")).length > 3 || payload.price.split(".").length > 2) {
+ toaster.error(strings.languageSpesific.invalid_form);
+ return;
+ }
+
+ payload.price = parseFloat(payload.price);
+
+ const action = isEditing ? updateProduct(payload) : createProduct(payload);
+ productForm.classList.add("loading");
+ submitProductFormButton.classList.add("disabled");
+ submitProductFormButtonSpinner.classList.remove("d-none");
+ action.then((res) => {
+ if (res.ok) {
+ shouldReloadProductsView = true;
+ productModal.hide();
+ sessionStorage.removeItem(configuration.storageKeys.productForm.imageUrls);
+ productForm.classList.remove("loading");
+ submitProductFormButton.classList.remove("disabled");
+ submitProductFormButtonSpinner.classList.add("d-none");
+ } else {
+ utilites.handleError(res, {
+ title: isEditing ? strings.languageSpesific.could_not_update_the_product : strings.languageSpesific.could_not_add_the_product,
+ message: "Prøv igjen snart",
+ });
+ productForm.classList.remove("loading");
+ submitProductFormButton.classList.remove("disabled");
+ submitProductFormButtonSpinner.classList.add("d-none");
+ }
+ }).catch((error) => {
+ console.error(error);
+ toaster.errorObj(messages.networkRequestFailed);
+ productForm.classList.remove("loading");
+ submitProductFormButton.classList.remove("disabled");
+ submitProductFormButtonSpinner.classList.add("d-none");
+ });
+}
+
+function disposeNewProductModal() {
+ if (shouldReloadProductsView) {
+ renderProductsView();
+ shouldReloadProductsView = false;
+ }
+ sessionStorage.removeItem(configuration.storageKeys.productForm.imageUrls);
+ productModalElement.dataset.isEditing = "false";
+ productForm.reset();
+ productForm.classList.remove("loading");
+ submitProductFormButton.removeEventListener("click", submitProductForm);
+ console.log("disposed newProductModal");
+}
+
+function openProductModal(edit = undefined, initalCategoryId = undefined) {
+ productForm.reset();
+ productModalElement.addEventListener("hidden.bs.modal", disposeNewProductModal, {once: true});
+ submitProductFormButton.addEventListener("click", submitProductForm);
+ utilites.dom.restrictInputToNumbers($("#input-price"), ["."], true);
+ utilites.dom.restrictInputToNumbers($("#input-count"), ["+", "-"], true);
+
+ if (edit !== undefined) {
+ if (!utilites.array.isEmpty(edit.images)) {
+ utilites.setSessionStorageJSON(configuration.storageKeys.productForm.imageUrls, edit.images);
+ }
+ renderCategoriesPicker(edit.category.id);
+ productModalElement.dataset.isEditing = "true";
+ productModalElement.dataset.id = edit.id;
+ $("#input-name").value = edit.name;
+ $("#input-price").value = edit.price;
+ $("#input-price-suffix").value = edit.priceSuffix;
+ $("#input-count").value = edit.count;
+ $("#show-on-frontpage").checked = edit.showOnFrontpage;
+ $("#input-description").value = edit.description;
+ productModalTitle.innerText = strings.languageSpesific.edit + " " + edit.name;
+ $("#submit-product-form .text").innerText = strings.languageSpesific.save;
+ } else {
+ sessionStorage.removeItem(configuration.storageKeys.productForm.imageUrls);
+ productModalTitle.innerText = strings.languageSpesific.new_product;
+ $("#submit-product-form .text").innerText = strings.languageSpesific.create;
+ productModalElement.dataset.isEditing = "false";
+ productModalElement.dataset.id = "";
+ if (initalCategoryId !== undefined)
+ renderCategoriesPicker(initalCategoryId);
+ else
+ renderCategoriesPicker();
+ }
+ renderProductFormImages();
+ productModal.show();
+}
+
+
+/*
+ CATEGORIES
+*/
+
+function removeCategory(id, name) {
+ if (!id || !name) return;
+ if (confirm(`${strings.languageSpesific.are_you_sure_you_want_to_delete} ${name}?`)) {
+ deleteCategory(id).then(res => {
+ if (res.ok) {
+ shouldReloadProductsView = true;
+ renderCategoriesList();
+ } else {
+ utilites.handleError(res, {
+ title: strings.languageSpesific.could_not_delete_category,
+ message: strings.languageSpesific.try_again_soon,
+ });
+ }
+ }).catch(err => {
+ console.error(err);
+ toaster.errorObj(messages.networkRequestFailed);
+ });
+ }
+}
+
+function setCategoryState(id, name, disabled) {
+ if (typeof disabled !== "boolean" || !id || !name) return;
+ const actionName = disabled ? ` ${strings.languageSpesific.hide_V.toLocaleLowerCase()} ` : ` ${strings.languageSpesific.show_V.toLocaleLowerCase()} `;
+ const confirmText = `${strings.languageSpesific.are_you_sure_you_want_to} ${actionName} ${name} ${strings.languageSpesific.in_the_store.toLocaleLowerCase()}?`;
+ if (confirm(confirmText)) {
+ const action = disabled ? disableCategory(id) : enableCategory(id);
+ action.then(res => {
+ if (res.ok) {
+ shouldReloadProductsView = true;
+ renderCategoriesList();
+ } else {
+ utilites.handleError(res, {
+ title: `${strings.languageSpesific.could_not} ${actionName} ${name}`,
+ message: strings.languageSpesific.try_again_soon,
+ });
+ }
+ }).catch(err => {
+ console.error(err);
+ toaster.errorObj(messages.networkRequestFailed);
+ });
+ }
+}
+
+function generateCategoryListItem(category) {
+ const nameEl = doc.createElement("div");
+ nameEl.className = "flex-grow-1";
+ nameEl.innerText = category.name;
+
+ const disabledCheckEl = doc.createElement("div");
+ const disabledCheckInputEl = doc.createElement("input");
+ disabledCheckEl.className = "form-check form-switch";
+
+ if (category.disabled === true) {
+ disabledCheckEl.title = `${strings.languageSpesific.show} ${category.name} ${strings.languageSpesific.in_the_store}`;
+ disabledCheckInputEl.checked = false;
+ disabledCheckInputEl.onclick = (e) => {
+ e.preventDefault();
+ disabledCheckInputEl.checked = false;
+ setCategoryState(category.id, category.name, false);
+ };
+ } else if (category.disabled === false) {
+ disabledCheckEl.title = `${strings.languageSpesific.hide} ${category.name} ${strings.languageSpesific.in_the_store}`;
+ disabledCheckInputEl.checked = true;
+ disabledCheckInputEl.onclick = (e) => {
+ e.preventDefault();
+ disabledCheckInputEl.checked = true;
+ setCategoryState(category.id, category.name, true);
+ };
+ }
+ disabledCheckInputEl.type = "checkbox";
+ disabledCheckInputEl.className = "form-check-input";
+ disabledCheckEl.appendChild(disabledCheckInputEl);
+
+ const deleteButtonEl = doc.createElement("button");
+ deleteButtonEl.className = "btn btn-link text-danger";
+ deleteButtonEl.title = "Slett \"" + category.name + "\" for godt";
+ deleteButtonEl.innerHTML = trashIcon("22", "22");
+ deleteButtonEl.onclick = () => removeCategory(category.id, category.name);
+
+ const secondParent = doc.createElement("div");
+ secondParent.className = "d-flex align-items-center";
+ secondParent.appendChild(nameEl);
+ secondParent.appendChild(disabledCheckEl);
+ secondParent.appendChild(deleteButtonEl);
+
+ const parent = doc.createElement("div");
+ parent.className = "list-group-item";
+ parent.dataset.id = category.id;
+ parent.appendChild(secondParent);
+
+ return parent;
+}
+
+function renderCategoriesList() {
+ categoryListElement.innerHTML = "";
+ getCategories().then(res => {
+ if (res.ok) {
+ res.json().then(data => {
+ data = utilites.resolveReferences(data);
+ data.forEach(category => {
+ categoryListElement.appendChild(generateCategoryListItem(category));
+ });
+ categoryListLoadingElement.classList.add("d-none");
+ categoryListElement.classList.remove("d-none");
+ });
+ } else {
+ utilites.handleError(res, {
+ title: strings.languageSpesific.could_not_retrieve_categories,
+ message: strings.languageSpesific.try_again_soon,
+ });
+ }
+ }).catch(err => {
+ console.error(err);
+ toaster.errorObj(messages.networkRequestFailed);
+ });
+}
+
+function handleKeyUpWhenCategoriesModalIsVisible(e) {
+ if (utilites.dom.elementHasFocus(newCategoryName) && e.key === "Enter") {
+ e.preventDefault();
+ if (newCategoryName.value) {
+ newCategoryForm.classList.add("loading");
+ createCategory(newCategoryName.value).then(res => {
+ if (res.ok) {
+ shouldReloadProductsView = true;
+ renderCategoriesList();
+ } else {
+ utilites.handleError(res, {
+ title: strings.languageSpesific.could_not_add_the_category,
+ message: strings.languageSpesific.try_again_soon,
+ });
+ }
+ newCategoryForm.classList.remove("loading");
+ }).catch(err => {
+ console.error(err);
+ toaster.errorObj(messages.networkRequestFailed);
+ });
+ } else {
+ toaster.error(strings.languageSpesific.name + " " + strings.languageSpesific.is_required_LC);
+ }
+ }
+}
+
+function disposeCategoriesModal() {
+ if (shouldReloadProductsView) {
+ renderProductsView();
+ shouldReloadProductsView = false;
+ }
+ newCategoryForm.reset();
+ doc.removeEventListener("keyup", handleKeyUpWhenCategoriesModalIsVisible);
+ console.log("disposed categoriesModal");
+}
+
+function openCategoriesModal() {
+ newCategoryForm.reset();
+ doc.addEventListener("keyup", handleKeyUpWhenCategoriesModalIsVisible);
+ categoryModalElement.addEventListener("hidden.bs.modal", disposeCategoriesModal, {
+ once: true,
+ });
+ renderCategoriesList();
+ categoryModal.show();
+}
diff --git a/src/wwwroot/scripts/base.js b/src/wwwroot/scripts/base.js
new file mode 100644
index 0000000..860cd34
--- /dev/null
+++ b/src/wwwroot/scripts/base.js
@@ -0,0 +1,84 @@
+import {Toaster} from "./toaster";
+import {configuration} from "./configuration";
+
+export const toaster = new Toaster();
+export const doc = document,
+ $$ = (s, o = doc) => o.querySelectorAll(s),
+ $ = (s, o = doc) => o.querySelector(s);
+
+function toggleDarkTheme() {
+ if (localStorage["dark-theme"] === "true") localStorage["dark-theme"] = "false";
+ else localStorage["dark-theme"] = "true";
+ setTheme();
+}
+
+function setTheme() {
+ if (localStorage["dark-theme"] === "true") {
+ document.getElementsByTagName("html")[0].setAttribute("dark-theme", "");
+ } else {
+ document.getElementsByTagName("html")[0].removeAttribute("dark-theme");
+ }
+}
+
+window.addEventListener("error", (e) => {
+ if (configuration.analytics.error) {
+ (async () => {
+ const error = {
+ msg: e.message,
+ line: e.lineno,
+ path: location.pathname,
+ filename: e.filename,
+ };
+ await fetch("/api/analytics/error", {
+ method: "post",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(error),
+ });
+ })();
+ }
+});
+
+window.addEventListener("load", () => {
+ if (configuration.analytics.pageload && !location.pathname.startsWith("/kontoret")) {
+ (async () => {
+ //const loadTime = window.performance.timing.domContentLoadedEventEnd -
+ // window.performance.timing.navigationStart;
+ const loadTime = window.performance.timeOrigin - window.performance.now();
+ await fetch("/api/analytics/pageload", {
+ method: "post",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({time: loadTime, path: location.pathname}),
+ });
+ })();
+ }
+});
+
+// https://stackoverflow.com/a/18234317/11961742
+String.prototype.formatUnicorn = String.prototype.formatUnicorn || function () {
+ "use strict";
+ let str = this.toString();
+ if (arguments.length) {
+ const t = typeof arguments[0];
+ const args = ("string" === t || "number" === t) ?
+ Array.prototype.slice.call(arguments)
+ : arguments[0];
+
+ for (const key in args) {
+ str = str.replace(new RegExp("\\{" + key + "\\}", "gi"), args[key]);
+ }
+ }
+
+ return str;
+};
+
+setTheme();
+
+export function pageInit(cb) {
+ if (typeof cb === "function") cb();
+}
+
+doc.addEventListener("DOMContentLoaded", pageInit);
diff --git a/src/wwwroot/scripts/cart-core.js b/src/wwwroot/scripts/cart-core.js
new file mode 100644
index 0000000..c15d0af
--- /dev/null
+++ b/src/wwwroot/scripts/cart-core.js
@@ -0,0 +1,109 @@
+import {utilites} from "./utilities";
+import {configuration} from "./configuration";
+import {getProducts} from "./api/products-api";
+
+export function setCart(data) {
+ return utilites.setSessionStorageJSON(configuration.storageKeys.cart, data);
+}
+
+export function getCart() {
+ return utilites.getSessionStorageJSON(configuration.storageKeys.cart);
+}
+
+export function cartDispose() {
+ sessionStorage.removeItem(configuration.storageKeys.cart);
+}
+
+export function priceTotal() {
+ const cartData = getCart();
+ if (utilites.array.isEmpty(cartData)) {
+ return null;
+ }
+ let total = null;
+ for (const product of cartData) {
+ total += product.price * product.count;
+ }
+ return typeof total !== "number" ? total : total.toFixed(2) + ",-";
+}
+
+export function priceTax() {
+ const cartData = getCart();
+ if (utilites.array.isEmpty(cartData)) {
+ return null;
+ }
+ let total = null;
+ for (const product of cartData) {
+ total += product.price * product.count;
+ }
+ return typeof total !== "number" ? total : ((total) * 25 / 100).toFixed(2) + ",-";
+}
+
+export function updatePricesFromApi() {
+ return new Promise((ok, error) => {
+ const cartData = getCart();
+ if (!cartData) {
+
+ ok();
+ return;
+ }
+ getProducts().then(res => {
+ if (res.ok) {
+ res.json().then(apiProducts => {
+ apiProducts = utilites.resolveReferences(apiProducts);
+ for (const latestProduct of apiProducts) {
+ const index = cartData.findIndex(c => c.id === latestProduct.id);
+ if (index !== -1) {
+ cartData[index].price = latestProduct.price;
+ }
+ if (index !== -1) {
+ cartData[index].readablePrice = latestProduct.price + latestProduct.readablePriceSuffix;
+ }
+ }
+ setCart(cartData);
+ ok();
+ });
+ }
+ });
+ });
+}
+
+export function setProductCount(id, newCount) {
+ const cartData = getCart();
+ const productIndex = cartData.findIndex(c => c.id === id);
+ cartData[productIndex].count = newCount;
+ setCart(cartData);
+}
+
+export function removeProductFromCart(id, callback) {
+ const cartData = getCart();
+ if (utilites.array.isEmpty(cartData)) {
+ return;
+ }
+ const updatedCart = utilites.array.removeItemByIdAll(cartData, {id});
+ if (updatedCart.length === 0) {
+ cartDispose();
+ } else {
+ setCart(updatedCart);
+ }
+ if (typeof callback === "function") {
+ callback(updatedCart);
+ }
+}
+
+export function addOrUpdateProduct(product, callback) {
+ let cartData = getCart();
+ if (utilites.array.isEmpty(cartData)) {
+ cartData = [];
+ }
+ const indexOfCurrent = cartData.length === 0 ? -1 : cartData.findIndex(c => c.id === product.id);
+ if (indexOfCurrent === -1 && product !== undefined) { // adding
+ product.count = 1;
+ cartData.push(product);
+ } else if (indexOfCurrent !== -1) { // updating
+ cartData[indexOfCurrent] = product;
+ }
+ setCart(cartData);
+ if (typeof callback === "function") {
+ callback();
+ }
+}
diff --git a/src/wwwroot/scripts/components.js b/src/wwwroot/scripts/components.js
new file mode 100644
index 0000000..28d25f0
--- /dev/null
+++ b/src/wwwroot/scripts/components.js
@@ -0,0 +1,48 @@
+import {configuration} from "./configuration";
+import {doc} from "./base";
+
+export function getCounterControl(options = configuration.defaultParameters.counterControlOptions) {
+ const wrapper = doc.createElement("div");
+ wrapper.className = "number-input";
+
+ const count = doc.createElement("input");
+ count.inputMode = "numeric";
+ count.type = "number";
+ if (options.min !== undefined) {
+ count.min = options.min;
+ }
+ if (options.max !== undefined) {
+ count.max = options.max;
+ }
+ if (options.initialCount !== undefined) {
+ count.value = options.initialCount;
+ }
+ if (options.onChange !== undefined && typeof options.onChange === "function") {
+ count.addEventListener("change", (e) => {
+ options.onChange(e);
+ });
+ }
+
+ const minus = doc.createElement("button");
+ minus.innerHTML = "&#45;";
+ minus.onclick = (e) => {
+ count.stepDown();
+ count.dispatchEvent(new Event("change"));
+ };
+
+ const plus = doc.createElement("button");
+ plus.innerHTML = "&#43;";
+ plus.onclick = (e) => {
+ count.stepUp();
+ count.dispatchEvent(new Event("change"));
+ };
+
+ wrapper.appendChild(minus);
+ wrapper.appendChild(count);
+ wrapper.appendChild(plus);
+ return wrapper;
+}
+
+export function getSpinner() {
+ return `<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Laster...</span></div>`;
+}
diff --git a/src/wwwroot/scripts/configuration.js b/src/wwwroot/scripts/configuration.js
new file mode 100644
index 0000000..418152d
--- /dev/null
+++ b/src/wwwroot/scripts/configuration.js
@@ -0,0 +1,41 @@
+export const configuration = {
+ storageKeys: {
+ cart: "cart",
+ productForm: {
+ imageUrls: "productForm.images",
+ },
+ },
+ debug: location.hostname !== "localhost",
+ analytics: {
+ pageload: false,
+ error: true,
+ },
+ cookies: {
+ culture: "vsh_culture",
+ },
+ paths: {
+ products: "/assets/images/products/",
+ documents: "/assets/images/documents/",
+ swaggerDoc: {
+ main: "/swagger/v1/swagger.json",
+ },
+ },
+ defaultParameters: {
+ counterControlOptions: {
+ min: undefined,
+ max: undefined,
+ initialCount: undefined,
+ onChange: undefined,
+ },
+ },
+ enums: {
+ orderStatus: {
+ IN_PROGRESS: 0,
+ CANCELLED: 0,
+ FAILED: 2,
+ COMPLETED: 3,
+ AWAITING_INVOICE: 4,
+ AWAITING_VIPPS: 5,
+ },
+ },
+};
diff --git a/src/wwwroot/scripts/front/handlekurv.js b/src/wwwroot/scripts/front/handlekurv.js
new file mode 100644
index 0000000..bb80b3e
--- /dev/null
+++ b/src/wwwroot/scripts/front/handlekurv.js
@@ -0,0 +1,256 @@
+import {utilites} from "../utilities";
+import {$, doc, pageInit} from "../base";
+import {
+ cartDispose,
+ getCart,
+ priceTax,
+ priceTotal,
+ removeProductFromCart,
+ setProductCount,
+ updatePricesFromApi,
+} from "../cart-core";
+
+import {getCounterControl} from "../components";
+import {strings} from "../i10n.ts";
+import {submitOrder} from "../api/order-api";
+import "bootstrap/js/dist/tab";
+
+const inputName = $("#input-name");
+const inputEmail = $("#input-email");
+const inputPhone = $("#input-phone");
+const inputComment = $("#input-comment");
+const invoiceMail = $("#invoice-mail");
+
+const orderSummaryWrapper = $("#order-summary-wrapper");
+const submitOrderWrapper = $("#submit-order-wrapper");
+const orderSummaryLoader = $("#order-summary-spinner");
+const total = $("#total-wrapper");
+
+function setOrderSummaryLoaderVisibility(show = false) {
+ if (show) {
+ orderSummaryWrapper.classList.add("d-none");
+ orderSummaryLoader.classList.remove("d-none");
+ } else {
+ orderSummaryWrapper.classList.remove("d-none");
+ orderSummaryLoader.classList.add("d-none");
+ }
+}
+
+function renderOverview() {
+ setOrderSummaryLoaderVisibility(true);
+ const cartData = getCart();
+ if (utilites.array.isEmpty(cartData)) {
+ const noItemsAlert = doc.createElement("div");
+ noItemsAlert.className = "alert alert-primary";
+ noItemsAlert.innerHTML = `${strings.languageSpesific.the_shopping_bag_is_empty}, ${strings.languageSpesific.go_to} <a href='/produktar'>/produktar</a> ${strings.languageSpesific.to_add_LC}`;
+ orderSummaryWrapper.appendChild(noItemsAlert);
+ setOrderSummaryLoaderVisibility(false);
+ orderSummaryWrapper.querySelector("table").classList.add("d-none");
+ total.classList.add("d-none");
+ submitOrderWrapper.classList.add("d-none");
+ return;
+ }
+ const tbody = orderSummaryWrapper.querySelector("tbody");
+ tbody.innerHTML = "";
+ for (const product of cartData)
+ tbody.appendChild(getProductTableRow(product));
+ updateTotal();
+ setOrderSummaryLoaderVisibility(false);
+}
+
+function updateTotal() {
+ total.querySelector("#total-sum").innerText = priceTotal();
+ total.querySelector("#total-tax").innerText = priceTax();
+}
+
+function getProductTableRow(product) {
+ const row = doc.createElement("tr");
+
+ const productCell = doc.createElement("td");
+ productCell.innerText = product.name;
+
+ const priceTotalCell = doc.createElement("td");
+ priceTotalCell.innerText = (product.price * product.count).toFixed(2) + ",-";
+
+ const countCell = doc.createElement("td");
+
+ const counterControl = getCounterControl({
+ initialCount: product.count,
+ min: "0",
+ max: product.maxCount,
+ onChange: (e) => {
+ const newCount = parseInt(e.target.value);
+ if (newCount === 0) {
+ removeProductFromCart(product.id, (updatedCart) => {
+ if (updatedCart.length >= 1) {
+ row.remove();
+ updateTotal();
+ } else {
+ renderOverview();
+ }
+ });
+ return;
+ }
+ setProductCount(product.id, newCount);
+ priceTotalCell.innerText = (product.price * newCount).toFixed(2) + ",-";
+ updateTotal();
+ },
+ });
+ counterControl.style.height = "30px";
+ countCell.appendChild(counterControl);
+
+ const priceCell = doc.createElement("td");
+ priceCell.innerText = product.readablePrice;
+
+ row.appendChild(productCell);
+ row.appendChild(countCell);
+ row.appendChild(priceCell);
+ row.appendChild(priceTotalCell);
+
+ return row;
+}
+
+function getOrderPayload() {
+ const payload = {
+ comment: inputComment.value,
+ contactInfo: {
+ name: inputName.value,
+ emailAddress: inputEmail.value,
+ phoneNumber: inputPhone.value,
+ },
+ products: [],
+ };
+ const cartData = getCart();
+ if (utilites.array.isEmpty(cartData)) return undefined;
+ for (const item of cartData) {
+ if (payload.products.findIndex(c => c.id === item.id) !== -1) continue;
+ payload.products.push({
+ id: item.id,
+ numberOfItems: item.count,
+ });
+ }
+ return payload;
+}
+
+function isSubmitOrderFormValid() {
+ return (
+ inputName.value &&
+ inputEmail.value &&
+ inputPhone.value &&
+ $("#vipps-terms-confirm").checked || $("#invoice-terms-confirm").checked
+ );
+}
+
+function submitOrderForm(type) {
+ if (isSubmitOrderFormValid()) {
+ const payload = getOrderPayload();
+ if (payload === undefined) return;
+ payload.paymentType = type;
+ $("#submit-order-form").classList.add("loading");
+ $("#form-errors").classList.add("d-none");
+ $("#form-errors").innerHTML = "";
+ submitOrder(payload).then(res => {
+ if (res.ok) {
+ res.text().then(continueTo => {
+ window.location.replace(continueTo.replaceAll("\"", ""));
+ });
+ } else {
+ console.log(res.headers.get("Content-Type"));
+ if (res.headers.get("Content-Type")?.startsWith("application/json")) {
+ res.json().then(errorJson => {
+ if (errorJson.isValid === false) {
+ errorJson = utilites.resolveReferences(errorJson);
+ for (const error of errorJson.errors) {
+ if (error.id == null) {
+ const listItem = doc.createElement("li");
+ if (error.errors.length === 1) {
+ listItem.innerText = error.errors[0];
+ } else {
+ let html;
+ for (const itemError of error.errors) {
+ html += itemError + "<br>";
+ }
+ listItem.innerHTML = html;
+ }
+ $("#form-errors").appendChild(listItem);
+ } else {
+ const cartproducts = getCart();
+ const errorProduct = cartproducts.find(c => c.id === error.id);
+ const listItem = doc.createElement("li");
+ if (error.errors.length === 1) {
+ listItem.innerHTML = `<b>${errorProduct.name}:</b> ${error.errors[0]}`;
+ } else {
+ let html = "";
+ for (const itemError of error.errors) {
+ html += `<b>${errorProduct.name}:</b> ${itemError} <br>`;
+ }
+ listItem.innerHTML = html;
+ }
+ $("#form-errors").appendChild(listItem);
+ }
+ }
+ $("#form-errors").classList.remove("d-none");
+ $("#submit-order-form").classList.remove("loading");
+ } else {
+ utilites.handleError(res, {
+ title: strings.languageSpesific.an_unknown_error_occured,
+ message: strings.languageSpesific.try_again_soon,
+ });
+ $("#submit-order-form").classList.remove("loading");
+ }
+ });
+ } else {
+ utilites.handleError(res, {
+ title: strings.languageSpesific.an_unknown_error_occured,
+ message: strings.languageSpesific.try_again_soon,
+ });
+ $("#submit-order-form").classList.remove("loading");
+ }
+ }
+ })
+ .catch(err => {
+ console.error(err);
+ });
+ }
+}
+
+window.disposeCart = () => {
+ cartDispose();
+ location.href = "/";
+};
+
+function handleCallbackError() {
+ const urlParams = new URLSearchParams(window.location.search);
+ const error = urlParams.get("error");
+ console.log(error);
+ switch (error) {
+ case "cancelled":
+ $("#order-alert").classList.remove("d-none");
+ $("#order-alert").innerHTML = "Din bestilling er kansellert! <span class='btn btn-link' onclick='disposeCart()' title='Slett handlekorgen'>Klikk her for å slette handlekorgen</span>";
+ break;
+ case "failed":
+ $("#order-alert").classList.remove("d-none");
+ $("#order-alert").innerHTML = "Bestillingen feilet, venlegst prøv igjen eller <a href='/#kontakt'>ta kontakt</a> med oss hvis problemet vedvarer!";
+ break;
+ }
+ urlParams.delete("error");
+}
+
+if (location.pathname.startsWith("/handlekorg")) {
+ pageInit(() => {
+ updatePricesFromApi().then(() => {
+ $(".submit-vipps").addEventListener("click", () => submitOrderForm(0));
+ $(".submit-invoice").addEventListener("click", () => submitOrderForm(1));
+ inputEmail.addEventListener("keyup", (e) => {
+ if (utilites.validators.isEmail(e.target.value)) {
+ invoiceMail.innerText = e.target.value;
+ } else {
+ invoiceMail.innerText = "din e-postadresse";
+ }
+ });
+
+ renderOverview();
+ });
+ handleCallbackError();
+ });
+}
diff --git a/src/wwwroot/scripts/front/logginn.js b/src/wwwroot/scripts/front/logginn.js
new file mode 100644
index 0000000..f15a323
--- /dev/null
+++ b/src/wwwroot/scripts/front/logginn.js
@@ -0,0 +1,92 @@
+import {$, pageInit} from "../base";
+import {utilites} from "../utilities";
+import {login} from "../api/account-api";
+import {strings} from "../i10n.ts";
+
+const submitButton = $("#submit");
+const submitButtonSpinner = $("#submit .spinner-border");
+const errorContainer = $("#error");
+const errorMessage = $("#error-message");
+const errorTitle = $("#error-title");
+const continueTo = new URL(location.href).searchParams.get("ReturnUrl")
+ ? new URL(location.href).searchParams.get("ReturnUrl")
+ : "/kontoret";
+const email = $("#input-email");
+const password = $("#input-password");
+const persist = $("#persist-session");
+const loginForm = $("#login-form");
+
+const loading = {
+ show() {
+ submitButton.classList.add("disabled");
+ submitButtonSpinner.classList.remove("d-none");
+ },
+ hide() {
+ submitButton.classList.remove("disabled");
+ submitButtonSpinner.classList.add("d-none");
+ },
+};
+
+const error = {
+ show(title = strings.languageSpesific.an_error_occured, message = strings.languageSpesific.try_again_soon) {
+ errorMessage.innerText = message;
+ errorTitle.innerText = title;
+ errorContainer.classList.remove("d-none");
+ },
+ hide() {
+ errorMessage.innerText = "";
+ errorTitle.innerText = "";
+ errorContainer.classList.add("d-none");
+ },
+};
+
+const form = {
+ submit() {
+ loading.show();
+ error.hide();
+ let payload = {
+ username: email.value,
+ password: password.value,
+ persist: persist.checked,
+ };
+ login(payload, $("input[name=\"xsrf\"]").value).then((response) => {
+ if (response.status === 200) {
+ error.hide();
+ location.href = continueTo;
+ } else {
+ utilites.handleError(response, {
+ title: strings.languageSpesific.an_unknown_error_occured,
+ message: strings.languageSpesific.try_again_soon,
+ });
+ loading.hide();
+ }
+ }).catch((err) => console.log(err));
+ },
+ isValid() {
+ email.removeAttribute("aria-invalid");
+ password.removeAttribute("aria-invalid");
+ if (!email.value || !utilites.validators.isEmail(email.value)) {
+ error.show(undefined, strings.languageSpesific.the_email_address + " " + strings.languageSpesific.is_invalid_LC);
+ email.setAttribute("aria-invalid", "true");
+ return false;
+ }
+ if (!password.value) {
+ error.show(undefined, strings.languageSpesific.the_password + " " + strings.languageSpesific.is_invalid_LC);
+ password.setAttribute("aria-invalid", "true");
+ return false;
+ }
+ return true;
+ },
+};
+
+if (location.pathname.startsWith("/logginn")) {
+ pageInit(() => {
+ loginForm.addEventListener("submit", (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (form.isValid()) {
+ form.submit();
+ }
+ });
+ });
+}
diff --git a/src/wwwroot/scripts/front/produkter.js b/src/wwwroot/scripts/front/produkter.js
new file mode 100644
index 0000000..86a2afb
--- /dev/null
+++ b/src/wwwroot/scripts/front/produkter.js
@@ -0,0 +1,341 @@
+import {validateOrderProducts} from "../api/order-api";
+import {$, $$, doc, pageInit, toaster} from "../base";
+import Modal from "bootstrap/js/dist/modal";
+import Carousel from "bootstrap/js/dist/carousel";
+import {utilites} from "../utilities";
+import {
+ addOrUpdateProduct,
+ cartDispose,
+ getCart,
+ priceTotal,
+ removeProductFromCart,
+ setProductCount,
+} from "../cart-core";
+import {getCounterControl} from "../components";
+import {messages} from "../messages";
+import {strings} from "../i10n.ts";
+
+const headerCartButton = $("#header-cart-button");
+const headerCartButtonCount = $("#header-cart-button #item-count");
+const cartViewRoot = $("#cart-modal #product-list");
+const submitCartButton = $("#cart-modal .submit-cart");
+const cartModalElement = $("#cart-modal");
+const cartModal = new Modal(cartModalElement, {
+ keyboard: false,
+});
+
+const isProductPage = location.pathname.startsWith("/produktar") && location.pathname.match(/\/./g).length === 3;
+const isProductsGridPage = location.pathname.startsWith("/produktar") && location.pathname.match(/\/./g).length <= 2;
+
+if (!location.pathname.startsWith("/handlekorg")) {
+ pageInit(() => {
+ renderHeaderIcon(false);
+ renderProductButtons();
+ cartModalElement.addEventListener("show.bs.modal", () => {
+ renderCartView();
+ renderTotalView();
+ });
+ headerCartButton.addEventListener("click", () => cartModal.toggle());
+ submitCartButton.addEventListener("click", () => submitCart());
+ });
+} else {
+ headerCartButton.classList.add("d-none");
+}
+
+if (location.pathname.match("(\/produktar\/.*\/.*)") !== null && $("#carousel-navigator") !== null) {
+ const carousel = new Carousel($("#product-carousel"));
+ $$(".thumb-button").forEach(el => {
+ const index = el.dataset.thumbIndex;
+ el.onclick = () => carousel.to(index);
+ });
+}
+
+function dispose() {
+ cartDispose();
+ renderHeaderIcon();
+ renderCartView();
+ renderTotalView();
+ renderProductButtons();
+}
+
+function renderProductButtons() {
+ const cartData = getCart();
+ if (isProductPage) {
+ const productWrapper = $("#single-product-wrapper");
+ const productId = productWrapper.dataset.id;
+ const productIndex = utilites.array.isEmpty(cartData) ? -1 : cartData.findIndex(c => c.id === productId);
+ const bagButton = productWrapper.querySelector(".buttons .bag-button") ?? doc.createElement("dummy");
+ const counterWrapper = productWrapper.querySelector(".buttons .counter-wrapper") ?? doc.createElement("dummy");
+ bagButton.classList.remove("disabled");
+ counterWrapper.innerHTML = "";
+
+ if (productIndex !== -1) {
+ const maxProductCount = bagButton.dataset.productMaxCount === "-1" ? 99999 : bagButton.dataset.productMaxCount;
+ const minProductCount = "0";
+ const product = cartData[productIndex];
+ bagButton.classList.add("d-none");
+ counterWrapper.appendChild(getCounterControl({
+ initialCount: product.count,
+ min: minProductCount,
+ max: maxProductCount,
+ onChange: (e) => {
+ let newCount = e.target.value;
+ if (newCount <= minProductCount) {
+ removeProduct(product.id);
+ bagButton.classList.remove("d-none");
+ return;
+ }
+
+ if (newCount > maxProductCount) newCount = maxProductCount;
+ setProductCount(product.id, parseInt(newCount));
+ renderHeaderIcon();
+ $$("[data-id='" + product.id + "'] .counter-wrapper").forEach(el => {
+ el.querySelector(".number-input input").value = parseInt(newCount);
+ });
+ },
+ }));
+ } else {
+ bagButton.innerHTML = "Legg i handlekorgen";
+ bagButton.onclick = () => addProduct(productId);
+ bagButton.classList.remove("d-none");
+ }
+ } else if (isProductsGridPage) {
+ $$(".product-card").forEach(productCard => {
+ const productId = productCard.dataset.id;
+ const bagButton = productCard.querySelector(".buttons .bag-button") ?? doc.createElement("dummy");
+ const counterWrapper = productCard.querySelector(".buttons .counter-wrapper") ?? doc.createElement("dummy");
+ const productIndex = utilites.array.isEmpty(cartData) ? -1 : cartData.findIndex(c => c.id === productId);
+ bagButton.classList.remove("disabled");
+ counterWrapper.innerHTML = "";
+
+ if (productIndex !== -1) {
+ const maxProductCount = bagButton.dataset.productMaxCount === "-1" ? 99999 : bagButton.dataset.productMaxCount;
+ const minProductCount = "0";
+ const product = cartData[productIndex];
+ bagButton.innerHTML = "Legg i handlekorgen";
+ bagButton.classList.add("d-none");
+ counterWrapper.appendChild(getCounterControl({
+ initialCount: product.count,
+ min: minProductCount,
+ max: maxProductCount,
+ onChange: (e) => {
+ let newCount = e.target.value;
+ if (newCount <= minProductCount) {
+ removeProduct(product.id);
+ bagButton.classList.remove("d-none");
+ return;
+ }
+ if (newCount > maxProductCount) newCount = maxProductCount;
+ setProductCount(product.id, parseInt(newCount));
+ renderHeaderIcon();
+ $$("[data-id='" + product.id + "'] .counter-wrapper").forEach(el => {
+ el.querySelector(".number-input input").value = parseInt(newCount);
+ });
+
+ },
+ }));
+ } else {
+ bagButton.innerHTML = "Legg i handlekorgen";
+ bagButton.onclick = () => addProduct(productId);
+ bagButton.classList.remove("d-none");
+ }
+ });
+ }
+}
+
+function getProductDataFromHtml(id) {
+ if (isProductsGridPage) {
+ const card = $(".product-card[data-id='" + id + "']");
+ if (card === undefined) return card;
+ const bagButton = card.querySelector(".bag-button");
+ return {
+ id: id,
+ name: card.querySelector(".card-title").innerText,
+ price: card.querySelector(".product-price").dataset.price,
+ readablePrice: card.querySelector(".product-price").innerText,
+ maxCount: bagButton.dataset.productMaxCount === "-1" ? 99999 : bagButton.dataset.productMaxCount,
+ };
+ } else if (isProductPage) {
+ const productWrapper = $("#single-product-wrapper[data-id='" + id + "']");
+ if (productWrapper === undefined) return productWrapper;
+ const bagButton = productWrapper.querySelector(".bag-button");
+ return {
+ id: id,
+ name: productWrapper.querySelector(".title").innerText,
+ description: productWrapper.querySelector(".description").innerText,
+ price: productWrapper.querySelector(".product-price").dataset.price,
+ readablePrice: productWrapper.querySelector(".product-price").innerText,
+ maxCount: bagButton.dataset.productMaxCount === "-1" ? 99999 : bagButton.dataset.productMaxCount,
+ };
+ }
+}
+
+function addProduct(id) {
+ addOrUpdateProduct(getProductDataFromHtml(id), () => {
+ renderHeaderIcon();
+ renderProductButtons();
+ });
+}
+
+function removeProduct(id) {
+ removeProductFromCart(id, () => {
+ renderHeaderIcon();
+ renderTotalView();
+ renderCartView();
+ renderProductButtons();
+ });
+}
+
+function renderHeaderIcon() {
+ const cartData = getCart();
+ if (utilites.array.isEmpty(cartData)) {
+ headerCartButtonCount.innerText = "0";
+ return;
+ }
+ const currentCount = parseInt(headerCartButtonCount.innerText);
+ let count = 0;
+ for (const product of cartData)
+ count += product.count;
+
+ if (currentCount !== count) {
+ headerCartButtonCount.innerText = count >= 1 ? count : "0";
+ }
+}
+
+function getProductListItemHTML(product) {
+ console.log(product);
+ const wrapper = doc.createElement("div");
+ wrapper.dataset.id = product.id;
+ wrapper.className = "mb-3";
+
+ const card = doc.createElement("div");
+ card.className = "card";
+
+ const cardHeaderWrapper = doc.createElement("div");
+ cardHeaderWrapper.className = "card-header d-flex justify-content-between align-items-center";
+
+ const cardHeader = doc.createElement("h5");
+ cardHeader.className = "mb-0";
+ cardHeader.innerText = product.name;
+ cardHeaderWrapper.appendChild(cardHeader);
+ const removeProductButton = doc.createElement("button");
+ removeProductButton.className = "btn btn-link shadow-none text-danger pe-0";
+ removeProductButton.innerText = "Slett";
+ removeProductButton.onclick = () => removeProduct(product.id);
+ cardHeaderWrapper.appendChild(removeProductButton);
+
+ card.appendChild(cardHeaderWrapper);
+ const cardBody = doc.createElement("div");
+ cardBody.className = "card-body";
+
+ const itemCountWrapper = doc.createElement("div");
+ itemCountWrapper.className = "d-flex justify-content-between align-items-center";
+
+ itemCountWrapper.appendChild(getCounterControl({
+ initialCount: product.count,
+ min: "0",
+ max: product.maxCount,
+ onChange: (e) => {
+ let newCount = e.target.value;
+ if (newCount <= "0") {
+ removeProduct(product.id);
+ return;
+ }
+ if (newCount > product.maxCount) newCount = product.maxCount;
+ setProductCount(product.id, parseInt(newCount));
+ renderHeaderIcon();
+ $$("[data-id='" + product.id + "'] .counter-wrapper").forEach(el => {
+ el.querySelector(".number-input input").value = newCount;
+ });
+ renderTotalView();
+ },
+ }));
+
+ const price = doc.createElement("div");
+ price.className = "h3";
+ price.innerText = product.readablePrice;
+ itemCountWrapper.appendChild(price);
+
+
+ cardBody.append(itemCountWrapper);
+ card.appendChild(cardBody);
+ wrapper.appendChild(card);
+ return wrapper;
+}
+
+function renderTotalView() {
+ const cartTotal = priceTotal();
+ if (cartTotal == null) {
+ $("#cart-modal .modal-footer").classList.add("d-none");
+ $("#total").innerText = "";
+ } else {
+ $("#cart-modal .modal-footer").classList.remove("d-none");
+ $("#total").innerText = cartTotal;
+ }
+}
+
+function renderCartView() {
+ const cartData = getCart();
+ cartViewRoot.innerHTML = "";
+ if (utilites.array.isEmpty(cartData)) {
+ const noItemsAlert = doc.createElement("div");
+ noItemsAlert.className = "alert alert-primary";
+ noItemsAlert.role = "alert";
+ noItemsAlert.innerHTML = `${strings.languageSpesific.the_shopping_bag_is_empty}, ${strings.languageSpesific.go_to_LC} <a href='/produktar'>/produktar</a> ${strings.languageSpesific.to_add_LC}`;
+ cartViewRoot.appendChild(noItemsAlert);
+ } else {
+ for (const product of cartData)
+ cartViewRoot.appendChild(getProductListItemHTML(product));
+ }
+}
+
+function validateCartModal() {
+ return new Promise((greatSuccess, graveFailure) => {
+ const payload = {
+ products: [],
+ };
+
+ const cartData = getCart();
+ if (utilites.array.isEmpty(cartData)) {
+ toaster.error(strings.languageSpesific.the_shopping_bag_is_empty);
+ return;
+ }
+
+ for (const product of cartData) {
+ payload.products.push({
+ id: product.id,
+ count: product.count,
+ });
+ }
+
+ validateOrderProducts(payload).then(res => {
+ if (res.ok) {
+ res.json().then(json => {
+ if (json.isValid) {
+ return greatSuccess(true);
+ } else {
+ toaster.error(strings.languageSpesific.invalid_form);
+ //TODO: show validation errors
+ return greatSuccess(false);
+ }
+ });
+ } else {
+ utilites.handleError(res, {
+ title: strings.languageSpesific.could_not_validate_your_order,
+ message: strings.languageSpesific.try_again_soon,
+ });
+ }
+ }).catch(err => {
+ console.error(err);
+ toaster.errorObj(messages.unknownError);
+ });
+ });
+}
+
+function submitCart() {
+ validateCartModal().then(isValid => {
+ if (isValid === true) {
+ location.href = "/handlekorg";
+ }
+ });
+}
diff --git a/src/wwwroot/scripts/front/status.js b/src/wwwroot/scripts/front/status.js
new file mode 100644
index 0000000..3addfac
--- /dev/null
+++ b/src/wwwroot/scripts/front/status.js
@@ -0,0 +1,10 @@
+import {cartDispose} from "../cart-core";
+import {pageInit} from "../base";
+
+if (location.pathname.startsWith("/status")) {
+ pageInit(() => {
+ const urlParams = new URLSearchParams(window.location.search);
+ const clearCart = urlParams.get("clearCart");
+ if (clearCart) cartDispose();
+ });
+}
diff --git a/src/wwwroot/scripts/grid.ts b/src/wwwroot/scripts/grid.ts
new file mode 100644
index 0000000..ecf708b
--- /dev/null
+++ b/src/wwwroot/scripts/grid.ts
@@ -0,0 +1,390 @@
+import * as JsSearch from "js-search";
+
+interface GridProps {
+ id?: string;
+ debug?: boolean;
+ data: Array<Object>;
+ columns: Array<GridColumn>;
+ pageSize?: number;
+ search?: SearchConfiguration;
+ classes?: GridClasses
+ strings?: GridStrings
+}
+
+interface GridConfiguration {
+ id: string;
+ debug: boolean;
+ data: Array<Object>;
+ columns: Array<GridColumn>;
+ pageSize: number;
+ search: SearchConfiguration;
+ classes: GridClasses,
+ strings: GridStrings
+}
+
+interface GridStrings {
+ previousPage: string,
+ nextPage: string,
+ search: string
+}
+
+interface GridClasses {
+ table: string,
+ thead: string
+}
+
+interface SearchConfiguration {
+ dataIds: Array<string | Array<string>>;
+}
+
+interface GridColumn {
+ dataId?: string | Array<string>;
+ columnName: string | Function;
+ cellValue?: string | Function | Element;
+ width?: string;
+ maxWidth?: string;
+ minWidth?: string;
+ className?: string;
+ click?: Function
+}
+
+export default class Grid {
+ private readonly canRender: boolean;
+ private readonly defaultOptions: GridConfiguration = {
+ id: "id",
+ debug: false,
+ columns: [],
+ data: [],
+ pageSize: 0,
+ classes: {
+ table: "table",
+ thead: "table-light"
+ },
+ search: {
+ dataIds: [],
+ },
+ strings: {
+ nextPage: "Neste",
+ previousPage: "Forrige",
+ search: "Søk"
+ }
+ };
+ private configuration: GridConfiguration;
+ private domElement: Element;
+ private searchIndex: JsSearch.Search;
+ public currentPage: number = 0;
+
+ constructor(props: GridProps) {
+ this.setOptions(props);
+ this.validateOptions();
+ this.canRender = true;
+ }
+
+ private setOptions(props: GridProps = this.defaultOptions): void {
+ this.configuration = {} as GridConfiguration;
+ this.configuration.columns = [];
+ for (const column of props.columns) {
+ if (isGridColumn(column)) {
+ this.configuration.columns.push(column);
+ } else {
+ this.log("column is not of type GridColumn: " + JSON.stringify(column));
+ }
+ }
+ this.configuration.data = props.data ?? this.defaultOptions.data;
+ this.configuration.id = props.id ?? this.defaultOptions.id;
+ this.configuration.debug = props.debug ?? this.defaultOptions.debug;
+ this.configuration.pageSize = props.pageSize ?? this.defaultOptions.pageSize;
+ this.configuration.classes = props.classes ?? this.defaultOptions.classes;
+ this.configuration.search = props.search ?? this.defaultOptions.search;
+ this.configuration.strings = props.strings ?? this.defaultOptions.strings;
+ }
+
+ private validateOptions(): void {
+ if (this.configuration.data.length === undefined) {
+ throw new GridError("props.data.length is undefined");
+ }
+ if (this.configuration.columns.length === undefined || this.configuration.columns.length <= 0) {
+ throw new GridError("props.columns.length is undefined or <= 0");
+ }
+ }
+
+ private renderCurrentPageIndicator(): void {
+ if (this.configuration.pageSize <= 0) return;
+ this.domElement.querySelectorAll(".pagination .page-item").forEach((el) => {
+ if (el.getAttribute("data-page-number") == this.currentPage.toString()) el.classList.add("active");
+ else el.classList.remove("active");
+ });
+ }
+
+ private renderPaginator(): void {
+ this.log("start renderPaginator");
+ if (this.configuration.pageSize <= 0 || this.configuration.data.length < this.configuration.pageSize) return;
+ const nav = document.createElement("nav");
+ nav.className = "float-right user-select-none";
+ const ul = document.createElement("ul");
+ ul.className = "pagination";
+ const previousItem = document.createElement("li");
+ previousItem.className = "page-item";
+ previousItem.onclick = () => this.navigate(this.currentPage - 1);
+ const previousLink = document.createElement("span");
+ previousLink.style.cursor = "pointer";
+ previousLink.className = "page-link";
+ previousLink.innerText = this.configuration.strings.previousPage;
+ previousItem.appendChild(previousLink);
+ ul.appendChild(previousItem);
+
+ for (let i = 0; i < this.configuration.data.length / this.configuration.pageSize; i++) {
+ const item = document.createElement("li");
+ item.className = "page-item";
+ item.dataset.pageNumber = i.toString();
+ item.onclick = () => this.navigate(i);
+ const link = document.createElement("span");
+ link.className = "page-link";
+ link.style.cursor = "pointer";
+ link.innerText = (i + 1).toString();
+ item.appendChild(link);
+ ul.appendChild(item);
+ }
+
+ const nextItem = document.createElement("li");
+ nextItem.className = "page-item";
+ nextItem.onclick = () => this.navigate(this.currentPage + 1);
+ const nextLink = document.createElement("span");
+ nextLink.style.cursor = "pointer";
+ nextLink.className = "page-link";
+ nextLink.innerText = this.configuration.strings.nextPage;
+ nextItem.appendChild(nextLink);
+
+ ul.appendChild(nextItem);
+ nav.appendChild(ul);
+ this.domElement.appendChild(nav);
+ this.log("end renderPaginator");
+ }
+
+ private renderWrapper(): void {
+ const wrapper = document.createElement("div");
+ wrapper.className = "table-responsive";
+ const table = document.createElement("table");
+ const thead = document.createElement("thead");
+ const tbody = document.createElement("tbody");
+ table.className = this.configuration.classes.table;
+ thead.className = this.configuration.classes.thead;
+ table.appendChild(thead);
+ table.appendChild(tbody);
+ wrapper.appendChild(table);
+ this.domElement.appendChild(wrapper);
+ }
+
+ private renderHead(): void {
+ const wrapper = this.domElement.querySelector("table thead");
+ wrapper.innerHTML = "";
+ const row = document.createElement("tr");
+ for (const col of this.configuration.columns) {
+ const th = document.createElement("th");
+ th.innerText = this.asString(col.columnName);
+ row.appendChild(th);
+ }
+ wrapper.appendChild(row);
+ }
+
+ // https://github.com/bvaughn/js-search/blob/master/source/getNestedFieldValue.js
+ private getNestedFieldValue<T>(object: Object, path: Array<string>): T {
+ path = path || [];
+ object = object || {};
+
+ let value = object;
+
+ // walk down the property path
+ for (let i = 0; i < path.length; i++) {
+ value = value[path[i]];
+ if (value == null) {
+ return null;
+ }
+ }
+
+ return value as T;
+ }
+
+ private renderBody(data: Array<Object> = null, isSearchResult: boolean = false): void {
+ let wrapper: Element;
+ if (isSearchResult) {
+ this.domElement.querySelector("table tbody:not(.search-results)").classList.add("d-none");
+ this.domElement.querySelector("table tbody.search-results").classList.remove("d-none");
+ wrapper = this.domElement.querySelector("table tbody.search-results");
+ } else {
+ this.domElement.querySelector("table tbody.search-results")?.classList.add("d-none");
+ this.domElement.querySelector("table tbody:not(.search-results)").classList.remove("d-none");
+ wrapper = this.domElement.querySelector("table tbody:not(.search-results)");
+ }
+ wrapper.innerHTML = "";
+ let items = data ?? this.configuration.data;
+ if (this.configuration.pageSize > 0) {
+ items = items.slice(0, this.configuration.pageSize);
+ }
+ for (const item of items) {
+ const row = document.createElement("tr");
+ // @ts-ignore
+ row.dataset.id = item[this.configuration.id];
+ for (const val of this.configuration.columns) {
+ const cell = document.createElement("td");
+ cell.className = "text-break";
+ if (val.width) cell.style.width = val.width;
+ if (val.maxWidth) cell.style.maxWidth = val.maxWidth;
+ if (val.minWidth) cell.style.minWidth = val.minWidth;
+
+ if (val.click instanceof Function) cell.onclick = () => val.click(item);
+ if (val.className) {
+ this.log(val.className)
+ val.className.split(" ").forEach(className => {
+ cell.classList.add(className)
+ })
+ }
+ if (val.cellValue instanceof Function) {
+ const computed = val.cellValue(item);
+ if (computed instanceof Element) cell.appendChild(computed);
+ else if (typeof computed === "string" || typeof computed === "number") cell.innerText = computed as string;
+ } else if (Array.isArray(val.dataId)) {
+ cell.innerText = this.getNestedFieldValue(item, val.dataId);
+ } else if (typeof val.dataId === "string" && val.dataId.length > 0) {
+ cell.innerText = item[val.dataId];
+ } else if (typeof val.cellValue === "string" || typeof val.cellValue === "number") {
+ cell.innerText = val.cellValue;
+ } else if (val.cellValue instanceof Element) {
+ cell.appendChild(val.cellValue);
+ }
+ row.appendChild(cell);
+ }
+ wrapper.appendChild(row);
+ }
+ }
+
+ private asString(val: string | Function, callbackData: any = undefined) {
+ if (val instanceof Function) {
+ return val(callbackData);
+ } else {
+ return val;
+ }
+ }
+
+ private static id(): string {
+ return Math.random().toString(36).substring(2) + Date.now().toString(36);
+ }
+
+ private log(message: any): void {
+ if (!this.configuration.debug) return;
+ console.log("Grid Debug: " + message);
+ }
+
+ private renderSearch() {
+ if (this.configuration.search.dataIds.length < 1) return;
+ const wrapper = document.createElement("div");
+ const searchInput = document.createElement("input");
+ searchInput.type = "text";
+ searchInput.className = "form-control";
+ searchInput.placeholder = this.configuration.strings.search;
+ searchInput.oninput = () => this.search(searchInput.value);
+ wrapper.appendChild(searchInput);
+ this.domElement.appendChild(wrapper);
+ }
+
+ private renderSearchResultWrapper() {
+ if (this.configuration.search.dataIds.length < 1) return;
+ const searchWrapper = document.createElement("tbody");
+ searchWrapper.className = "search-results";
+ this.domElement.querySelector("table").appendChild(searchWrapper);
+ }
+
+ private populateSearchIndex() {
+ if (this.configuration.search.dataIds.length < 1) return;
+ this.searchIndex = new JsSearch.Search(this.configuration.id);
+ this.searchIndex.indexStrategy = new JsSearch.PrefixIndexStrategy();
+ this.configuration.search.dataIds.forEach((id) => this.searchIndex.addIndex(id));
+ this.searchIndex.addDocuments(this.configuration.data);
+ }
+
+ public search(query: string): void {
+ if (this.configuration.search.dataIds.length < 1) return;
+ let result = this.searchIndex.search(query);
+ if (result.length === 0) {
+ this.renderBody(this.configuration.data);
+ } else {
+ this.renderBody(result, true);
+ }
+ }
+
+ public navigate(pageNumber: number): void {
+ const maxPage = Math.ceil(this.configuration.data.length / this.configuration.pageSize - 1);
+ if (this.configuration.pageSize <= 0 || pageNumber < 0 || pageNumber === this.currentPage || pageNumber > maxPage) return;
+ this.log("Navigating to page: " + pageNumber);
+ const skipCount = this.configuration.pageSize * pageNumber;
+ const endIndex =
+ this.configuration.data.length < skipCount + this.configuration.pageSize
+ ? this.configuration.data.length
+ : skipCount + this.configuration.pageSize;
+ this.renderBody(this.configuration.data.slice(skipCount, endIndex));
+ this.currentPage = pageNumber;
+ this.renderCurrentPageIndicator();
+ }
+
+ public removeByID(id: string): void {
+ // @ts-ignore
+ const itemIndex = this.configuration.data.findIndex((c) => c[this.configuration.id] === id);
+ if (itemIndex !== -1) {
+ delete this.configuration.data[itemIndex];
+ this.domElement.querySelector(`tr[data-id="${id}"]`).remove();
+ } else {
+ this.log("Grid does not contain id: " + id);
+ }
+ }
+
+ public refresh(data?: Array<Object>) {
+ this.renderPaginator();
+ this.navigate(0);
+ if (data !== undefined) this.configuration.data = data;
+ this.renderBody();
+ this.populateSearchIndex();
+ }
+
+ public render(el: Element): void {
+ if (this.canRender) {
+ this.log("Grid starting render");
+ this.domElement = el;
+ this.renderSearch();
+ this.populateSearchIndex();
+ this.renderWrapper();
+ this.renderHead();
+ this.renderBody();
+ this.renderPaginator();
+ this.renderCurrentPageIndicator();
+ this.renderSearchResultWrapper();
+ this.log("Grid was rendered");
+ } else {
+ throw new GridError("render is not allowed due to invalid props");
+ }
+ }
+}
+
+class GridError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "GridError";
+ }
+}
+
+function isArrayOfGridColumn(array: Array<GridColumn> | Array<string>): array is Array<GridColumn> {
+ try {
+ // @ts-ignore
+ return !(array.map((element) => "name" in element).indexOf(false) !== -1);
+ } catch (e) {
+ return false;
+ }
+}
+
+function isGridColumn(val: GridColumn | string): val is GridColumn {
+ try {
+ // @ts-ignore
+ return "columnName" in val && ("cellValue" in val || "dataId" in val);
+ } catch (e) {
+ return false;
+ }
+}
diff --git a/src/wwwroot/scripts/i10n.ts b/src/wwwroot/scripts/i10n.ts
new file mode 100644
index 0000000..e26b0be
--- /dev/null
+++ b/src/wwwroot/scripts/i10n.ts
@@ -0,0 +1,95 @@
+/**
+ * _LC = lowercase
+ * _V = verb
+ */
+
+/*
+import {configuration} from "./configuration";
+import Cookies from "js-cookie";
+*/
+
+const versions = [
+ {
+ id: "nb",
+ isDefault: true,
+ value: {
+ could_not_reach_server: "Kunne ikkje kontakte serveren",
+ an_unknown_error_occured: "Ein uventa feil oppstod",
+ an_error_occured: "Ein feil oppstod",
+ try_again_soon: "Prøv igjen snart",
+ new_password_is_applied: "Nytt passord er sett",
+ are_you_sure: "Er du sikker?",
+ are_you_sure_you_want_to_delete: "Er du sikker på at du vil slette",
+ is_deleted_LC: "er sletta",
+ has_invalid_file_format_LC: "har ugyldig filformat",
+ delete: "Slett",
+ cancel: "Kanseller",
+ view: "Inspiser",
+ edit: "Rediger",
+ invalid_form: "Ugyldig skjema",
+ the_product_is_updated: "Produktet er oppdatert",
+ the_product_is_added: "Produktet er lagt til",
+ could_not: "Kunne ikkje",
+ could_not_update_the_product: "Kunne ikkje oppdatere produktet",
+ could_not_add_the_product: "Kunne ikkje legge til produktet",
+ could_not_add_the_category: "Kunne ikkje legge til kategorien",
+ could_not_retrieve_products: "Kunne ikkje hente produktene",
+ could_not_retrieve_orders: "Kunne ikkje hente bestillingene",
+ could_not_retrieve_categories: "Kunne ikkje hente kategoriene",
+ could_not_delete_product: "Kunne ikkje slette produktet",
+ could_not_delete_category: "Kunne ikkje slette kategorien",
+ could_not_upload: "Kunne ikkje laste opp",
+ too_many_files: "For mange filer",
+ max_five_files_at_a_time: "Maks 5 filer om gangen",
+ invalid_file: "Ugyldig fil",
+ is_too_big_LC: "er for stor",
+ too_big_file: "For stor fil",
+ the_image: "Bildet",
+ the_images: "Bildene",
+ is_uploaded_LC: "er lasta opp",
+ picture_of_the_product: "Bilde av produktet",
+ save: "Lagre",
+ new_product: "Nytt produkt",
+ create: "Opprett",
+ hide_V: "Gjemme",
+ show_V: "Vise",
+ show: "Vis",
+ hide: "Gjem",
+ are_you_sure_you_want_to: "Er du sikker på at du vil",
+ in_the_store: "I butikken",
+ is_required_LC: "er påkrevd",
+ is_invalid_LC: "er ugyldig",
+ name: "Namn",
+ the_email_address: "E-postadressen",
+ the_password: "Passordet",
+ could_not_validate_your_order: "Kunne ikkje validere din ordre",
+ the_shopping_bag_is_empty: "Handlekorgen er tom",
+ to_add_LC: "for å legge til",
+ go_to: "Gå til",
+ go_to_LC: "gå til",
+ click_on: "Trykk på",
+ search: "Søk",
+ next_page: "Neste side",
+ previous_page: "Forrige side",
+ },
+ },
+];
+
+/*function getIsoCodeFromCultureCookie(v) {
+ if (v === undefined) return "";
+ return v.slice(v.lastIndexOf('=') + 1)
+}
+// uses js-cookie
+const prefferedLanguage = getIsoCodeFromCultureCookie(Cookies.get(configuration.cookies.culture));
+const indexOfPrefferedLanguage = versions.findIndex(version => version.id === prefferedLanguage);
+const indexOfDefaultLanguage = versions.findIndex(version => version.isDefault === true);
+export const strings = {
+ languageSpesific: indexOfPrefferedLanguage !== -1
+ ? versions[indexOfPrefferedLanguage].value
+ : versions[indexOfDefaultLanguage].value,
+};
+ */
+
+export const strings = {
+ languageSpesific: versions[0].value
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/icons.js b/src/wwwroot/scripts/icons.js
new file mode 100644
index 0000000..6fa3559
--- /dev/null
+++ b/src/wwwroot/scripts/icons.js
@@ -0,0 +1,58 @@
+export function bagDashIcon(height = "1em", width = "1em") {
+ return `
+<svg width="${height}" height="${height}" viewBox="0 0 16 16" class="bi bi-bag-plus" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
+ <path fill-rule="evenodd" d="M8 1a2.5 2.5 0 0 0-2.5 2.5V4h5v-.5A2.5 2.5 0 0 0 8 1zm3.5 3v-.5a3.5 3.5 0 1 0-7 0V4H1v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V4h-3.5zM2 5v9a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5H2z"/>
+ <path fill-rule="evenodd" d="M5.5 10a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 0 1H6a.5.5 0 0 1-.5-.5z"/>
+</svg>
+`;
+}
+
+export function bagPlusIcon(height = "1em", width = "1em") {
+ return `
+<svg width="${height}" height="${height}" viewBox="0 0 16 16" class="bi bi-bag-plus" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
+ <path fill-rule="evenodd" d="M8 1a2.5 2.5 0 0 0-2.5 2.5V4h5v-.5A2.5 2.5 0 0 0 8 1zm3.5 3v-.5a3.5 3.5 0 1 0-7 0V4H1v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V4h-3.5zM2 5v9a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V5H2z"/>
+ <path fill-rule="evenodd" d="M8 7.5a.5.5 0 0 1 .5.5v1.5H10a.5.5 0 0 1 0 1H8.5V12a.5.5 0 0 1-1 0v-1.5H6a.5.5 0 0 1 0-1h1.5V8a.5.5 0 0 1 .5-.5z"/>
+</svg>
+`;
+}
+
+export function trashIcon(height = "1em", width = "1em") {
+ return `
+<svg width="${height}" height="${height}" viewBox="0 0 16 16" class="bi bi-trash2" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
+ <path fill-rule="evenodd" d="M3.18 4l1.528 9.164a1 1 0 0 0 .986.836h4.612a1 1 0 0 0 .986-.836L12.82 4H3.18zm.541 9.329A2 2 0 0 0 5.694 15h4.612a2 2 0 0 0 1.973-1.671L14 3H2l1.721 10.329z"/>
+ <path d="M14 3c0 1.105-2.686 2-6 2s-6-.895-6-2 2.686-2 6-2 6 .895 6 2z"/>
+ <path fill-rule="evenodd" d="M12.9 3c-.18-.14-.497-.307-.974-.466C10.967 2.214 9.58 2 8 2s-2.968.215-3.926.534c-.477.16-.795.327-.975.466.18.14.498.307.975.466C5.032 3.786 6.42 4 8 4s2.967-.215 3.926-.534c.477-.16.795-.327.975-.466zM8 5c3.314 0 6-.895 6-2s-2.686-2-6-2-6 .895-6 2 2.686 2 6 2z"/>
+</svg>`;
+}
+
+export function plusIcon(height = "1em", width = "1em") {
+ return `
+<svg width="${width}" height="${height}" viewBox="0 0 16 16" class="bi bi-plus" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
+ <path fill-rule="evenodd" d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
+</svg>
+`;
+}
+
+export function pencilSquareIcon(height = "1em", width = "1em") {
+ return `
+<svg width="${width}" height="${height}" viewBox="0 0 16 16" class="bi bi-pencil-square" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
+ <path d="M15.502 1.94a.5.5 0 0 1 0 .706L14.459 3.69l-2-2L13.502.646a.5.5 0 0 1 .707 0l1.293 1.293zm-1.75 2.456l-2-2L4.939 9.21a.5.5 0 0 0-.121.196l-.805 2.414a.25.25 0 0 0 .316.316l2.414-.805a.5.5 0 0 0 .196-.12l6.813-6.814z"/>
+ <path fill-rule="evenodd" d="M1 13.5A1.5 1.5 0 0 0 2.5 15h11a1.5 1.5 0 0 0 1.5-1.5v-6a.5.5 0 0 0-1 0v6a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-11a.5.5 0 0 1 .5-.5H9a.5.5 0 0 0 0-1H2.5A1.5 1.5 0 0 0 1 2.5v11z"/>
+</svg>
+`;
+}
+
+export function threeDotsVerticalIcon(height = "1em", width = "1em") {
+ return `
+<svg width="${width}" height="${height}" viewBox="0 0 16 16" class="bi bi-three-dots-vertical" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
+ <path fill-rule="evenodd" d="M9.5 13a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0zm0-5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
+</svg>`;
+}
+
+export function eyeIcon(height = "1em", width = "1em") {
+ return `
+<svg width="${width}" height="${height}" viewBox="0 0 16 16" class="bi bi-eye" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
+ <path fill-rule="evenodd" d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.134 13.134 0 0 0 1.66 2.043C4.12 11.332 5.88 12.5 8 12.5c2.12 0 3.879-1.168 5.168-2.457A13.134 13.134 0 0 0 14.828 8a13.133 13.133 0 0 0-1.66-2.043C11.879 4.668 10.119 3.5 8 3.5c-2.12 0-3.879 1.168-5.168 2.457A13.133 13.133 0 0 0 1.172 8z"/>
+ <path fill-rule="evenodd" d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
+</svg>`;
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/messages.js b/src/wwwroot/scripts/messages.js
new file mode 100644
index 0000000..a426677
--- /dev/null
+++ b/src/wwwroot/scripts/messages.js
@@ -0,0 +1,12 @@
+import {strings} from "./i10n.ts";
+
+export const messages = {
+ networkRequestFailed: {
+ title: strings.languageSpesific.could_not_reach_server,
+ message: strings.languageSpesific.try_again_soon,
+ },
+ unknownError: {
+ title: strings.languageSpesific.an_unknown_error_occured,
+ message: strings.languageSpesific.try_again_soon,
+ },
+}; \ No newline at end of file
diff --git a/src/wwwroot/scripts/tabs.js b/src/wwwroot/scripts/tabs.js
new file mode 100644
index 0000000..69d670c
--- /dev/null
+++ b/src/wwwroot/scripts/tabs.js
@@ -0,0 +1,31 @@
+export function initTabs() {
+ let firstActiveIsShown = false;
+ document.querySelectorAll(".tab-content-container[data-tab]").forEach(contentContainer => {
+ if (contentContainer.classList.contains("active") && !firstActiveIsShown) {
+ firstActiveIsShown = true;
+ return;
+ }
+ contentContainer.style.display = "none";
+ });
+
+ document.querySelectorAll(".tab-button[data-tab]").forEach(button => {
+ button.addEventListener("click", handleButtonClick);
+ });
+
+ function handleButtonClick(element) {
+ if (element.originalTarget.dataset.tab) {
+ document.querySelectorAll(".tab-button[data-tab]").forEach(b => {
+ if (b.dataset.tab === element.originalTarget.dataset.tab)
+ b.classList.add("active");
+ else
+ b.classList.remove("active");
+ });
+ document.querySelectorAll(".tab-content-container[data-tab]").forEach(c => {
+ if (c.dataset.tab === element.originalTarget.dataset.tab)
+ c.style.display = "";
+ else
+ c.style.display = "none";
+ });
+ }
+ }
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/toaster.js b/src/wwwroot/scripts/toaster.js
new file mode 100644
index 0000000..c0e039d
--- /dev/null
+++ b/src/wwwroot/scripts/toaster.js
@@ -0,0 +1,131 @@
+import Toast from "bootstrap/js/dist/toast";
+
+export class Toaster {
+ constructor(options) {
+ if (!options) {
+ options = {hideAfter: 3500, position: "top-right"};
+ }
+ this.root = document.createElement("div");
+ this.root.id = "toaster-container";
+ switch (options.position) {
+ case"top-right":
+ this.root.style = "display: flex; align-items: end; flex-direction: column; position: absolute; top: 0; right: 0; padding: 15px; z-index: 9999;";
+ break;
+ case"top-left":
+ this.root.style = "position: absolute; top: 0; padding: 15px; z-index: 9999;";
+ break;
+ case"bottom-right":
+ this.root.style = "display: flex; align-items: end; flex-direction: column; position: absolute; bottom: 0; right: 0; padding: 15px; z-index: 9999;";
+ break;
+ case"bottom-left":
+ this.root.style = "position: absolute; bottom: 0; padding: 15px; z-index: 9999;";
+ break;
+ default:
+ this.root.style = "display: flex; align-items: end; flex-direction: column; position: absolute; top: 0; right: 0; padding: 15px; z-index: 9999;";
+ break;
+ }
+ document.body.appendChild(this.root);
+ this.defaultTimeout = options.hideAfter ? options.hideAfter : 3500;
+ this.toastTypes = {error: "error", success: "success", info: "info"};
+ }
+
+ display(title, message, autohide, type) {
+ if (!title || typeof title !== "string" || typeof message !== "string") {
+ throw new Error("Toaster: title &| message is empty or not a string");
+ }
+ let toast = document.createElement("div");
+ toast.className = "toast";
+ toast.role = "alert";
+ toast.id = Math.random().toString(36).substring(2) + Date.now().toString(36);
+ let toastHeader = document.createElement("div");
+ toastHeader.className = "toast-header";
+ let toastTypeIndicator = document.createElement("div");
+ switch (type) {
+ case this.toastTypes.error:
+ toastTypeIndicator.className = "toast-type-indicator rounded me-2 bg-danger";
+ toast.style = "width: max-content; min-width: 300px; border-color: var(--bs-danger);";
+ break;
+ case this.toastTypes.success:
+ toastTypeIndicator.className = "toast-type-indicator rounded me-2 bg-success";
+ toast.style = "width: max-content; min-width: 300px; border-color: var(--bs-success);";
+ break;
+ case this.toastTypes.info:
+ toastTypeIndicator.className = "toast-type-indicator rounded me-2 bg-info";
+ toast.style = "width: max-content; min-width: 300px; border-color: var(--bs-info);";
+ break;
+ }
+ toastTypeIndicator.style = "width: 18px; height: 18px;";
+ toastHeader.appendChild(toastTypeIndicator);
+ let toastTitle = document.createElement("strong");
+ toastTitle.className = "me-auto text-truncate";
+ toastTitle.innerText = title;
+ toastHeader.appendChild(toastTitle);
+ let toastCloseButton = document.createElement("button");
+ toastCloseButton.className = "ms-2 mb-1 close";
+ toastCloseButton.setAttribute("data-bs-dismiss","toast");
+ toastCloseButton.onclick = () => {
+ setTimeout(() => {
+ toast.parentNode.removeChild(toast);
+ }, parseInt(1e3));
+ };
+ if (!autohide) {
+ let toastCloseButtonIcon = document.createElement("img");
+ toastCloseButtonIcon.src = "data:image/svg+xml,<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1em\" viewBox=\"0 0 16 16\"><path fill-rule=\"evenodd\" d=\"M14 1H2a1 1 0 00-1 1v12a1 1 0 001 1h12a1 1 0 001-1V2a1 1 0 00-1-1zM2 0a2 2 0 00-2 2v12a2 2 0 002 2h12a2 2 0 002-2V2a2 2 0 00-2-2H2z\" clip-rule=\"evenodd\"/><path fill-rule=\"evenodd\" d=\"M11.854 4.146a.5.5 0 010 .708l-7 7a.5.5 0 01-.708-.708l7-7a.5.5 0 01.708 0z\" clip-rule=\"evenodd\"/><path fill-rule=\"evenodd\" d=\"M4.146 4.146a.5.5 0 000 .708l7 7a.5.5 0 00.708-.708l-7-7a.5.5 0 00-.708 0z\" clip-rule=\"evenodd\"/></svg>";
+ toastCloseButtonIcon.alt = "x";
+ toastCloseButtonIcon.dataset.ariaHidden = "true";
+ toastCloseButton.appendChild(toastCloseButtonIcon);
+ toastHeader.appendChild(toastCloseButton);
+ }
+ toast.appendChild(toastHeader);
+ if (message) {
+ let toastBody = document.createElement("div");
+ toastBody.className = "toast-body";
+ toastBody.style = "word-wrap: break-word;";
+ toastBody.innerText = message;
+ toast.appendChild(toastBody);
+ }
+ this.root.appendChild(toast);
+ let delay = 0;
+ if (typeof autohide === "number" && autohide !== 0) {
+ delay = autohide;
+ autohide = true;
+ } else if (autohide) {
+ delay = this.defaultTimeout;
+ }
+ new Toast(document.getElementById(toast.id), {
+ animation: true,
+ autohide: autohide,
+ delay: delay
+ }).show();
+ if (delay) setTimeout(() => {
+ toast.parentNode.removeChild(toast);
+ }, parseInt(delay + 1e3));
+ }
+
+ error(title, message = "", autohide = true) {
+ this.display(title, message, autohide, this.toastTypes.error);
+ }
+
+ errorObj(props) {
+ if (props.autohide === undefined) props.autohide = true;
+ this.display(props.title, props.message, props.autohide, this.toastTypes.error);
+ }
+
+ success(title, message = "", autohide = true) {
+ this.display(title, message, autohide, this.toastTypes.success);
+ }
+
+ successObj(props) {
+ if (props.autohide === undefined) props.autohide = true;
+ this.display(props.title, props.message, props.autohide, this.toastTypes.success);
+ }
+
+ info(title, message = "", autohide = true) {
+ this.display(title, message, autohide, this.toastTypes.info);
+ }
+
+ infoObj(props) {
+ if (props.autohide === undefined) props.autohide = true;
+ this.display(props.title, props.message, props.autohide, this.toastTypes.info);
+ }
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/utilities.js b/src/wwwroot/scripts/utilities.js
new file mode 100644
index 0000000..02af8b5
--- /dev/null
+++ b/src/wwwroot/scripts/utilities.js
@@ -0,0 +1,254 @@
+import {toaster} from "./base";
+import {messages} from "./messages";
+
+export const utilites = {
+ dom: {
+ restrictInputToNumbers(el, specials, mergeSpecialsWithDefaults) {
+ el.addEventListener("keydown", e => {
+ const defaultSpecials = [
+ "Backspace",
+ "ArrowLeft",
+ "ArrowRight",
+ "Tab",
+ ];
+ let keys = specials ?? defaultSpecials;
+ if (mergeSpecialsWithDefaults && specials) keys = [...specials, ...defaultSpecials];
+ if (keys.indexOf(e.key) !== -1) {
+ return;
+ }
+ if (isNaN(parseInt(e.key))) {
+ e.preventDefault();
+ }
+ });
+ },
+ elementHasFocus: (el) => el === document.activeElement,
+ moveFocus(element) {
+ if (!element) element = document.getElementsByTagName("body")[0];
+ element.focus();
+ if (!this.elementHasFocus(element)) {
+ element.setAttribute("tabindex", "-1");
+ element.focus();
+ }
+ },
+ },
+ array: {
+ isEmpty: (arr) => arr === undefined || !Array.isArray(arr) || arr.length <= 0,
+ removeItemOnce(arr, value) {
+ const index = arr.indexOf(value);
+ if (index > -1) {
+ arr.splice(index, 1);
+ }
+ return arr;
+ },
+ pushOrUpdate(arr, obj) {
+ const index = arr.findIndex((e) => e.id === obj.id);
+ if (index === -1) {
+ arr.push(obj);
+ } else {
+ arr[index] = obj;
+ }
+ },
+ removeItemAll(arr, value) {
+ let i = 0;
+ while (i < arr.length) {
+ if (arr[i] === value) {
+ arr.splice(i, 1);
+ } else {
+ ++i;
+ }
+ }
+ return arr;
+ },
+ removeItemByIdAll(arr, value) {
+ let i = 0;
+ while (i < arr.length) {
+ if (arr[i].id === value.id) {
+ arr.splice(i, 1);
+ } else {
+ ++i;
+ }
+ }
+ return arr;
+ },
+ },
+ object: {
+ // http://youmightnotneedjquery.com/#deep_extend
+ extend(object, ext) {
+ ext = ext || {};
+ for (let i = 1; i < object.length; i++) {
+ let obj = object[i];
+ if (!obj) continue;
+ for (let key in obj) {
+ if (obj.hasOwnProperty(key)) {
+ if (typeof obj[key] === "object") {
+ if (obj[key] instanceof Array)
+ ext[key] = obj[key].slice(0);
+ else
+ ext[key] = this.extend(ext[key], obj[key]);
+ } else
+ ext[key] = obj[key];
+ }
+ }
+ }
+ return ext;
+ },
+ isEmptyObj(obj) {
+ return obj !== undefined && Object.keys(obj).length > 0;
+ },
+ },
+ validators: {
+ isEmail(input) {
+ const re = /\S+@\S+\.\S+/;
+ return re.test(input);
+ },
+ },
+ getRandomInt(min, max) {
+ min = Math.ceil(min);
+ max = Math.floor(max);
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+ },
+ isInternetExplorer() {
+ const ua = navigator.userAgent;
+ return ua.indexOf("MSIE") > -1 || ua.indexOf("Trident") > -1;
+ },
+ toReadableDateString(date) {
+ date.setMinutes(date.getMinutes() - date.getTimezoneOffset());
+ return date.toLocaleString("nb-NO");
+ },
+ toReadableBytes(bytes) {
+ const s = ["bytes", "kB", "MB", "GB", "TB", "PB"];
+ const e = Math.floor(Math.log(bytes) / Math.log(1024));
+ return (bytes / Math.pow(1024, e)).toFixed(2) + " " + s[e];
+ },
+ toReadablePriceSuffix(suffix) {
+ switch (suffix) {
+ case 0:
+ return ",-";
+ case 1:
+ return ",- kg";
+ case 2:
+ return ",- stk";
+ }
+ },
+ setSessionStorageJSON(key, object) {
+ sessionStorage.setItem(key, JSON.stringify(object));
+ },
+ getSessionStorageJSON(key) {
+ const dataString = sessionStorage.getItem(key);
+ if (dataString === null) return undefined;
+ return JSON.parse(dataString);
+ },
+ setLocalStorageJSON(key, object) {
+ localStorage.setItem(key, JSON.stringify(object));
+ },
+ getLocalStorageJSON(key) {
+ const dataString = localStorage.getItem(key);
+ if (dataString === null) return undefined;
+ return JSON.parse(dataString);
+ },
+ handleError: (httpResult, fallback) => {
+ if (httpResult.headers !== undefined && httpResult.headers.get("ContentType") === "application/json") {
+ httpResult.json().then(error => {
+ console.error(error);
+ toaster.errorObj(utilites.errorOrDefault(error, fallback));
+ });
+ } else {
+ toaster.errorObj(fallback);
+ }
+ },
+ getOrderStatusName: (status) => {
+ switch (status) {
+ case 0:
+ return "Pågående";
+ case 1:
+ return "Kansellert";
+ case 2:
+ return "Feilet";
+ case 3:
+ return "Fullført";
+ case 4:
+ return "Venter på faktura";
+ case 5:
+ return "Venter på vipps";
+ default:
+ return "Ukjent";
+ }
+ },
+ getOrderPaymentName: (status) => {
+ switch (status) {
+ case 0:
+ return "Vipps";
+ case 1:
+ return "Faktura på mail";
+ default:
+ return "Ukjent";
+ }
+ },
+ errorOrDefault: (res, fallback) => {
+ let title;
+ let message;
+
+ if (res.title) title = res.title;
+ else if (fallback.title) title = fallback.title;
+ else title = messages.unknownError.title;
+
+ if (res.message) message = res.message;
+ else if (fallback.message) message = fallback.message;
+ else message = messages.unknownError.message;
+
+ return {
+ title,
+ message,
+ };
+ },
+ // https://stackoverflow.com/a/15757499/11961742
+ resolveReferences(json) {
+ if (typeof json === "string")
+ json = JSON.parse(json);
+
+ const byid = {}, // all objects by id
+ refs = []; // references to objects that could not be resolved
+ json = (function recurse(obj, prop, parent) {
+ if (typeof obj !== "object" || !obj) // a primitive value
+ return obj;
+ if (Object.prototype.toString.call(obj) === "[object Array]") {
+ for (let i = 0; i < obj.length; i++) {
+ // check also if the array element is not a primitive value
+ if (typeof obj[i] !== "object" || !obj[i]) {// a primitive value
+ continue;
+ } else if ("$ref" in obj[i]) {
+ obj[i] = recurse(obj[i], i, obj);
+ } else {
+ obj[i] = recurse(obj[i], prop, obj);
+ }
+ }
+ return obj;
+ }
+ if ("$ref" in obj) { // a reference
+ let ref = obj.$ref;
+ if (ref in byid)
+ return byid[ref];
+ // else we have to make it lazy:
+ refs.push([parent, prop, ref]);
+ return;
+ } else if ("$id" in obj) {
+ let id = obj.$id;
+ delete obj.$id;
+ if ("$values" in obj) // an array
+ obj = obj.$values.map(recurse);
+ else // a plain object
+ for (let prop in obj)
+ obj[prop] = recurse(obj[prop], prop, obj);
+ byid[id] = obj;
+ }
+ return obj;
+ })(json); // run it!
+
+ for (let i = 0; i < refs.length; i++) { // resolve previously unknown references
+ let ref = refs[i];
+ ref[0][ref[1]] = byid[ref[2]];
+ // Notice that this throws if you put in a reference at top-level
+ }
+ return json;
+ },
+};
diff --git a/src/wwwroot/scripts/vendor/cycle.js b/src/wwwroot/scripts/vendor/cycle.js
new file mode 100644
index 0000000..10262a9
--- /dev/null
+++ b/src/wwwroot/scripts/vendor/cycle.js
@@ -0,0 +1,174 @@
+/*
+ cycle.js
+ 2018-05-15
+ Public Domain.
+ NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK.
+ This code should be minified before deployment.
+ See http://javascript.crockford.com/jsmin.html
+ USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO
+ NOT CONTROL.
+*/
+
+// The file uses the WeakMap feature of ES6.
+
+/*jslint eval */
+
+/*property
+ $ref, decycle, forEach, get, indexOf, isArray, keys, length, push,
+ retrocycle, set, stringify, test
+*/
+
+export function decycle(object, replacer) {
+ "use strict";
+
+// Make a deep copy of an object or array, assuring that there is at most
+// one instance of each object or array in the resulting structure. The
+// duplicate references (which might be forming cycles) are replaced with
+// an object of the form
+
+// {"$ref": PATH}
+
+// where the PATH is a JSONPath string that locates the first occurance.
+
+// So,
+
+// var a = [];
+// a[0] = a;
+// return JSON.stringify(JSON.decycle(a));
+
+// produces the string '[{"$ref":"$"}]'.
+
+// If a replacer function is provided, then it will be called for each value.
+// A replacer function receives a value and returns a replacement value.
+
+// JSONPath is used to locate the unique object. $ indicates the top level of
+// the object or array. [NUMBER] or [STRING] indicates a child element or
+// property.
+
+ var objects = new WeakMap(); // object to path mappings
+
+ return (function derez(value, path) {
+
+// The derez function recurses through the object, producing the deep copy.
+
+ var old_path; // The path of an earlier occurance of value
+ var nu; // The new object or array
+
+// If a replacer function was provided, then call it to get a replacement value.
+
+ if (replacer !== undefined) {
+ value = replacer(value);
+ }
+
+// typeof null === "object", so go on if this value is really an object but not
+// one of the weird builtin objects.
+
+ if (
+ typeof value === "object"
+ && value !== null
+ && !(value instanceof Boolean)
+ && !(value instanceof Date)
+ && !(value instanceof Number)
+ && !(value instanceof RegExp)
+ && !(value instanceof String)
+ ) {
+
+// If the value is an object or array, look to see if we have already
+// encountered it. If so, return a {"$ref":PATH} object. This uses an
+// ES6 WeakMap.
+
+ old_path = objects.get(value);
+ if (old_path !== undefined) {
+ return {$ref: old_path};
+ }
+
+// Otherwise, accumulate the unique value and its path.
+
+ objects.set(value, path);
+
+// If it is an array, replicate the array.
+
+ if (Array.isArray(value)) {
+ nu = [];
+ value.forEach(function (element, i) {
+ nu[i] = derez(element, path + "[" + i + "]");
+ });
+ } else {
+
+// If it is an object, replicate the object.
+
+ nu = {};
+ Object.keys(value).forEach(function (name) {
+ nu[name] = derez(
+ value[name],
+ path + "[" + JSON.stringify(name) + "]",
+ );
+ });
+ }
+ return nu;
+ }
+ return value;
+ }(object, "$"));
+}
+
+
+export function retrocycle($) {
+ "use strict";
+
+// Restore an object that was reduced by decycle. Members whose values are
+// objects of the form
+// {$ref: PATH}
+// are replaced with references to the value found by the PATH. This will
+// restore cycles. The object will be mutated.
+
+// The eval function is used to locate the values described by a PATH. The
+// root object is kept in a $ variable. A regular expression is used to
+// assure that the PATH is extremely well formed. The regexp contains nested
+// * quantifiers. That has been known to have extremely bad performance
+// problems on some browsers for very long strings. A PATH is expected to be
+// reasonably short. A PATH is allowed to belong to a very restricted subset of
+// Goessner's JSONPath.
+
+// So,
+// var s = '[{"$ref":"$"}]';
+// return JSON.retrocycle(JSON.parse(s));
+// produces an array containing a single element which is the array itself.
+
+ var px = /^\$(?:\[(?:\d+|"(?:[^\\"\u0000-\u001f]|\\(?:[\\"\/bfnrt]|u[0-9a-zA-Z]{4}))*")\])*$/;
+
+ (function rez(value) {
+
+// The rez function walks recursively through the object looking for $ref
+// properties. When it finds one that has a value that is a path, then it
+// replaces the $ref object with a reference to the value that is found by
+// the path.
+
+ if (value && typeof value === "object") {
+ if (Array.isArray(value)) {
+ value.forEach(function (element, i) {
+ if (typeof element === "object" && element !== null) {
+ var path = element.$ref;
+ if (typeof path === "string" && px.test(path)) {
+ value[i] = eval(path);
+ } else {
+ rez(element);
+ }
+ }
+ });
+ } else {
+ Object.keys(value).forEach(function (name) {
+ var item = value[name];
+ if (typeof item === "object" && item !== null) {
+ var path = item.$ref;
+ if (typeof path === "string" && px.test(path)) {
+ value[name] = eval(path);
+ } else {
+ rez(item);
+ }
+ }
+ });
+ }
+ }
+ }($));
+ return $;
+} \ No newline at end of file
diff --git a/src/wwwroot/scripts/vendor/quill.js b/src/wwwroot/scripts/vendor/quill.js
new file mode 100644
index 0000000..fa7e400
--- /dev/null
+++ b/src/wwwroot/scripts/vendor/quill.js
@@ -0,0 +1,25 @@
+import Quill from "quill/core";
+
+import Toolbar from "quill/modules/toolbar";
+import Snow from "quill/themes/snow";
+
+import Bold from "quill/formats/bold";
+import Italic from "quill/formats/italic";
+import Header from "quill/formats/header";
+import ImageUploader from "quill-image-uploader";
+import BlotFormatter from "quill-blot-formatter";
+import {ImageDrop} from "quill-image-drop-module";
+
+Quill.register({
+ "modules/toolbar": Toolbar,
+ "modules/imageDrop": ImageDrop,
+ "modules/blotFormatter": BlotFormatter,
+ "modules/imageUploader": ImageUploader,
+ "themes/snow": Snow,
+ "formats/bold": Bold,
+ "formats/italic": Italic,
+ "formats/header": Header,
+});
+
+
+export default Quill;