mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-24 22:58:28 +01:00
260 lines
17 KiB
Text
260 lines
17 KiB
Text
@using BTCPayServer.Plugins.PointOfSale.Models
|
|
@using BTCPayServer.Services
|
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
|
@using Newtonsoft.Json.Linq
|
|
@using BTCPayServer.Client
|
|
@using BTCPayServer.Abstractions.TagHelpers
|
|
@inject DisplayFormatter DisplayFormatter
|
|
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
|
|
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
|
|
@{
|
|
Layout = "PointOfSale/Public/_Layout";
|
|
Csp.UnsafeEval();
|
|
}
|
|
@section PageHeadContent {
|
|
<link href="~/pos/cart.css" asp-append-version="true" rel="stylesheet" />
|
|
}
|
|
@section PageFootContent {
|
|
<script>const srvModel = @Safe.Json(Model);</script>
|
|
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
|
|
<script src="~/pos/common.js" asp-append-version="true"></script>
|
|
<script src="~/pos/cart.js" asp-append-version="true"></script>
|
|
}
|
|
@functions {
|
|
private string GetItemPriceFormatted(ViewPointOfSaleViewModel.Item item)
|
|
{
|
|
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup) return "any amount";
|
|
if (item.Price == 0) return "free";
|
|
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
|
|
return item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
|
|
}
|
|
}
|
|
|
|
<div id="PosCart">
|
|
<div id="content">
|
|
<div class="public-page-wrap">
|
|
<header class="sticky-top bg-body d-flex flex-column py-3 py-lg-4 gap-3">
|
|
<div class="d-flex align-items-center justify-content-center gap-3 px-5 position-relative">
|
|
<h1 class="mb-0">@(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title)</h1>
|
|
<button id="CartToggle" class="cart-toggle-btn" type="button" v-on:click="toggleCart" aria-controls="cart" :disabled="cartCount === 0">
|
|
<vc:icon symbol="pos-cart" />
|
|
<span id="CartBadge" class="badge rounded-pill bg-danger p-1 ms-1" v-text="cartCount" v-if="cartCount !== 0"></span>
|
|
</button>
|
|
<button type="button" class="btn btn-link p-1" data-bs-toggle="modal" data-bs-target="#RecentTransactions" id="RecentTransactionsToggle" permission="@Policies.CanViewInvoices">
|
|
<vc:icon symbol="nav-invoice"/>
|
|
</button>
|
|
</div>
|
|
@if (Model.ShowSearch)
|
|
{
|
|
<input id="SearchTerm" class="form-control rounded-pill" placeholder="Search…" v-model="searchTerm" v-if="showSearch">
|
|
}
|
|
@if (Model.ShowCategories)
|
|
{
|
|
<div id="Categories" ref="categories" v-if="showCategories && allCategories" class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-3" :class="{ 'scrollable': categoriesScrollable }">
|
|
<nav class="btcpay-pills d-flex align-items-center gap-3" ref="categoriesNav">
|
|
<template v-for="cat in allCategories">
|
|
<input :id="`Category-${cat.value}`" type="radio" name="category" autocomplete="off" v-model="displayCategory" :value="cat.value">
|
|
<label :for="`Category-${cat.value}`" class="btcpay-pill text-nowrap">{{ cat.text }}</label>
|
|
</template>
|
|
</nav>
|
|
</div>
|
|
}
|
|
</header>
|
|
<main>
|
|
<partial name="_StatusMessage" />
|
|
@if (!string.IsNullOrEmpty(Model.Description))
|
|
{
|
|
<div class="lead">@Safe.Raw(Model.Description)</div>
|
|
}
|
|
<div ref="posItems" class="row row-cols row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-2 row-cols-xl-3 row-cols-xxl-4 g-4" id="PosItems">
|
|
@for (var index = 0; index < Model.Items.Length; index++)
|
|
{
|
|
var item = Model.Items[index];
|
|
var formatted = GetItemPriceFormatted(item);
|
|
var inStock = item.Inventory is null or > 0;
|
|
var buttonText = string.IsNullOrEmpty(item.BuyButtonText)
|
|
? item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText
|
|
: item.BuyButtonText;
|
|
buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
|
|
var categories = new JArray(item.Categories ?? new object[] { });
|
|
<div class="col posItem posItem--displayed" :class="{ 'posItem--inStock': inStock(@index) }" data-index="@index" data-search="@Safe.RawEncode(item.Title + " " + item.Description)" data-categories='@Safe.Json(categories)'>
|
|
<div class="tile card" v-on:click="addToCart(@index, 1)">
|
|
@if (!string.IsNullOrWhiteSpace(item.Image))
|
|
{
|
|
<img class="card-img-top" src="@item.Image" alt="@item.Title" asp-append-version="true">
|
|
}
|
|
<div class="card-body d-flex flex-column gap-2 mb-auto">
|
|
<h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
|
|
<div class="d-flex gap-2 align-items-center">
|
|
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup || item.Price == 0)
|
|
{
|
|
<span class="fw-semibold badge text-bg-info">@Safe.Raw(char.ToUpper(formatted[0]) + formatted[1..])</span>
|
|
}
|
|
else
|
|
{
|
|
<span class="fw-semibold">@Safe.Raw(formatted)</span>
|
|
}
|
|
@if (item.Inventory.HasValue)
|
|
{
|
|
<span class="badge text-bg-warning inventory" v-text="inventoryText(@index)">
|
|
@(item.Inventory > 0 ? $"{item.Inventory} left" : "Sold out")
|
|
</span>
|
|
}
|
|
</div>
|
|
@if (!string.IsNullOrWhiteSpace(item.Description))
|
|
{
|
|
<p class="card-text">@Safe.Raw(item.Description)</p>
|
|
}
|
|
</div>
|
|
@if (inStock)
|
|
{
|
|
<form class="card-footer">
|
|
@if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Fixed)
|
|
{
|
|
<div class="input-group mb-2">
|
|
<span class="input-group-text">@Model.CurrencySymbol</span>
|
|
<input class="form-control" type="number" min="@(item.Price ?? 0)" step="@Model.Step" name="amount" placeholder="Amount" value="@item.Price" required v-on:click.stop>
|
|
</div>
|
|
}
|
|
<button type="button" class="btn btn-primary w-100" :disabled="!inStock(@index)">
|
|
@Safe.Raw(buttonText)
|
|
</button>
|
|
</form>
|
|
<div class="posItem-added"><vc:icon symbol="checkmark" /></div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</main>
|
|
<footer class="store-footer">
|
|
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
|
|
Powered by <partial name="_StoreFooterLogo" />
|
|
</a>
|
|
</footer>
|
|
</div>
|
|
</div>
|
|
<aside id="cart" ref="cart" tabindex="-1" aria-labelledby="cartLabel">
|
|
<div class="public-page-wrap" v-cloak>
|
|
<header class="sticky-top bg-tile offcanvas-header py-3 py-lg-4 d-flex align-items-baseline justify-content-center gap-3 px-5 pe-lg-0">
|
|
<h1 class="mb-0" id="cartLabel">Cart</h1>
|
|
<button id="CartClear" type="reset" v-on:click="clear" class="btn btn-text text-primary p-1" v-if="cartCount > 0">
|
|
Empty
|
|
</button>
|
|
<button id="CartClose" type="button" class="cart-toggle-btn" v-on:click="toggleCart" aria-controls="cart" aria-label="Close">
|
|
<vc:icon symbol="cross" />
|
|
</button>
|
|
</header>
|
|
<div class="offcanvas-body py-0">
|
|
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" v-on:submit="handleFormSubmit" v-if="cartCount !== 0">
|
|
<input type="hidden" name="amount" :value="totalNumeric">
|
|
<input type="hidden" name="tip" :value="tipNumeric">
|
|
<input type="hidden" name="discount" :value="discountPercentNumeric">
|
|
<input type="hidden" name="posdata" :value="posdata">
|
|
<table class="table table-borderless mt-0 mb-4">
|
|
<tbody id="CartItems">
|
|
<tr v-for="item in cart" :key="item.id">
|
|
<td class="align-middle">
|
|
<h6 class="fw-semibold mb-1">{{ item.title }}</h6>
|
|
<button type="button" v-on:click="removeFromCart(item.id)" class="btn btn-sm btn-link p-0 text-danger fw-semibold">Remove</button>
|
|
</td>
|
|
<td class="align-middle">
|
|
<div class="d-flex align-items-center gap-2 justify-content-end quantity">
|
|
<span class="badge text-bg-warning inventory" v-if="item.inventory">
|
|
{{ item.inventory > 0 ? `${item.inventory} left` : "Sold out" }}
|
|
</span>
|
|
<div class="d-flex align-items-center gap-2 quantities">
|
|
<button type="button" v-on:click="updateQuantity(item.id, -1)" class="btn btn-minus">
|
|
<span><vc:icon symbol="minus" /></span>
|
|
</button>
|
|
<input class="form-control hide-number-spin w-50px text-center" type="number" placeholder="Qty" min="1" step="1" :max="item.inventory" v-model.number="item.count">
|
|
<button type="button" v-on:click="updateQuantity(item.id, +1)" class="btn btn-plus">
|
|
<span><vc:icon symbol="plus" /></span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="align-middle text-end">
|
|
{{ formatCurrency(item.price||0, true) }}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<table class="table table-borderless my-4" v-if="showDiscount || enableTips">
|
|
<tr v-if="showDiscount">
|
|
<th class="align-middle">Discount</th>
|
|
<th class="align-middle" colspan="3">
|
|
<div class="input-group input-group-sm w-100px pull-right">
|
|
<input class="form-control hide-number-spin" type="number" min="0" step="1" max="100" id="Discount" v-model.number="discountPercent">
|
|
<span class="input-group-text">%</span>
|
|
</div>
|
|
</th>
|
|
</tr>
|
|
<tr v-if="enableTips">
|
|
<th class="align-middle">Tip</th>
|
|
<th class="align-middle" colspan="3">
|
|
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-end gap-2" v-if="customTipPercentages">
|
|
<div class="btcpay-pill d-flex align-items-center px-3" id="Tip-Custom" :class="{ active: !tipPercent && tip }" v-on:click.prevent="tipPercent = null">
|
|
<input
|
|
v-model.number="tip"
|
|
class="form-control hide-number-spin shadow-none text-reset d-block bg-transparent border-0 p-0 me-1 fw-semibold"
|
|
style="height:1.5em;min-height:auto;width:4ch"
|
|
type="number"
|
|
min="0"
|
|
step="@Model.Step" />
|
|
<span>@(Model.CurrencyInfo.CurrencySymbol ?? Model.CurrencyCode)</span>
|
|
</div>
|
|
<button
|
|
v-for="percentage in customTipPercentages"
|
|
type="button"
|
|
class="btcpay-pill px-3"
|
|
:class="{ active: tipPercent == percentage }"
|
|
:id="`Tip-${percentage}`"
|
|
v-on:click.prevent="tipPercentage(percentage)">
|
|
{{ percentage }}%
|
|
</button>
|
|
</div>
|
|
</th>
|
|
</tr>
|
|
</table>
|
|
<table class="table table-borderless mt-4 mb-0">
|
|
<tr>
|
|
<td class="align-middle">Subtotal</td>
|
|
<td class="align-middle text-end" id="CartAmount">{{ formatCurrency(amountNumeric, true) }}</td>
|
|
</tr>
|
|
<tr v-if="discountNumeric">
|
|
<td class="align-middle">Discount</td>
|
|
<td class="align-middle text-end" id="CartDiscount">
|
|
<span v-if="discountPercent">{{discountPercent}}% =</span>
|
|
{{ formatCurrency(discountNumeric, true) }}
|
|
</td>
|
|
</tr>
|
|
<tr v-if="tipNumeric">
|
|
<td class="align-middle">Tip</td>
|
|
<td class="align-middle text-end" id="CartTip">
|
|
<span v-if="tipPercent">{{tipPercent}}% =</span>
|
|
{{ formatCurrency(tipNumeric, true) }}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td class="align-middle h5 border-0">Total</td>
|
|
<td class="align-middle h5 border-0 text-end" id="CartTotal">{{ formatCurrency(totalNumeric, true) }}</td>
|
|
</tr>
|
|
<tr>
|
|
<td colspan="2" class="pt-4">
|
|
<button id="CartSubmit" class="btn btn-primary btn-lg w-100" :disabled="payButtonLoading" type="submit">
|
|
<div v-if="payButtonLoading" class="spinner-border spinner-border-sm" id="pay-button-spinner" role="status">
|
|
<span class="visually-hidden">Loading...</span>
|
|
</div>
|
|
<template v-else>Pay</template>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</table>
|
|
</form>
|
|
<p id="CartItems" v-else class="text-muted text-center my-0">There are no items in your cart yet.</p>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
<partial name="PointOfSale/Public/RecentTransactions" model="Model"/>
|
|
</div>
|