diff options
Diffstat (limited to 'src/wwwroot/scripts')
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 = "-"; + minus.onclick = (e) => { + count.stepDown(); + count.dispatchEvent(new Event("change")); + }; + + const plus = doc.createElement("button"); + plus.innerHTML = "+"; + 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; |
