Refactor: Move PoS views in PoS plugin folder
What changed, and why it matters
This commit is a straightforward code reorganization: it moves the Point of Sale (PoS) user-interface files from a shared views folder into the dedicated Point of Sale plugin folder, and updates the corresponding file paths in the controller and plugin registration. There is no functional change to how the application behaves, and no security fix or vulnerability is introduced.
No security action required. Treat as a normal refactoring commit. Verify during routine review that view resolution still works and that plugin packaging includes the relocated files.
Security signals we found
No strong security signals were identified.
Evidence from the diff
The change relocates Razor views for the BTCPay Server Point of Sale plugin from BTCPayServer/Views/Shared/PointOfSale/ to BTCPayServer/Plugins/PointOfSale/Views/. The controller (UIPointOfSaleController.cs) and plugin entry point (PointOfSalePlugin.cs) are updated to reference the new absolute view paths (e.g., /Plugins/PointOfSale/Views/Public/{viewType}.cshtml). The content of the views is essentially identical; only paths and layout references changed. No security-sensitive logic was modified.
Changed components
BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.csBTCPayServer/Plugins/PointOfSale/PointOfSalePlugin.csBTCPayServer/Plugins/PointOfSale/Views/NavExtension.cshtmlBTCPayServer/Plugins/PointOfSale/Views/Public/Cart.cshtmlBTCPayServer/Plugins/PointOfSale/Views/Public/Light.cshtmlBTCPayServer/Plugins/PointOfSale/Views/Public/MinimalLight.cshtmlBTCPayServer/Plugins/PointOfSale/Views/Public/Print.cshtmlBTCPayServer/Plugins/PointOfSale/Views/Public/RecentTransactions.cshtmlBTCPayServer/Plugins/PointOfSale/Views/Public/Static.cshtmlBTCPayServer/Plugins/PointOfSale/Views/Public/VueLight.cshtmlBTCPayServer/Plugins/PointOfSale/Views/Public/_Layout.cshtmlBTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtmlInspect captured patch +1276 / −1275
diff --git a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
index 60b8af1..d625e01 100644
--- a/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
+++ b/BTCPayServer/Plugins/PointOfSale/Controllers/UIPointOfSaleController.cs
@@ -111,7 +111,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
var storeBlob = store.GetStoreBlob();
var storeBranding = await StoreBrandingViewModel.CreateAsync(Request, _uriResolver, storeBlob);
- return View($"PointOfSale/Public/{viewType}", new ViewPointOfSaleViewModel
+ return View($"/Plugins/PointOfSale/Views/Public/{viewType}.cshtml", new ViewPointOfSaleViewModel
{
Title = settings.Title,
StoreName = store.StoreName,
@@ -611,7 +611,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
vm.ExampleCallback = "{\n \"id\":\"SkdsDghkdP3D3qkj7bLq3\",\n \"url\":\"https://btcpay.example.com/invoice?id=SkdsDghkdP3D3qkj7bLq3\",\n \"status\":\"paid\",\n \"price\":10,\n \"currency\":\"EUR\",\n \"invoiceTime\":1520373130312,\n \"expirationTime\":1520374030312,\n \"currentTime\":1520373179327,\n \"exceptionStatus\":false,\n \"buyerFields\":{\n \"buyerEmail\":\"customer@example.com\",\n \"buyerNotify\":false\n },\n \"paymentSubtotals\": {\n \"BTC\":114700\n },\n \"paymentTotals\": {\n \"BTC\":118400\n },\n \"transactionCurrency\": \"BTC\",\n \"amountPaid\": \"1025900\",\n \"exchangeRates\": {\n \"BTC\": {\n \"EUR\": 8721.690715789999,\n \"USD\": 10817.99\n }\n }\n}";
await FillUsers(vm);
- return View("PointOfSale/UpdatePointOfSale", vm);
+ return View("/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml", vm);
}
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
@@ -624,7 +624,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
vm.Id = app.Id;
if (!ModelState.IsValid)
- return View("PointOfSale/UpdatePointOfSale", vm);
+ return View("/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml", vm);
vm.Currency = await GetStoreDefaultCurrentIfEmpty(app.StoreDataId, vm.Currency);
if (_currencies.GetCurrencyData(vm.Currency, false) == null)
@@ -640,7 +640,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (!ModelState.IsValid)
{
await FillUsers(vm);
- return View("PointOfSale/UpdatePointOfSale", vm);
+ return View("/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml", vm);
}
var settings = new PointOfSaleSettings
diff --git a/BTCPayServer/Plugins/PointOfSale/PointOfSalePlugin.cs b/BTCPayServer/Plugins/PointOfSale/PointOfSalePlugin.cs
index ccd9da1..2ada14b 100644
--- a/BTCPayServer/Plugins/PointOfSale/PointOfSalePlugin.cs
+++ b/BTCPayServer/Plugins/PointOfSale/PointOfSalePlugin.cs
@@ -28,7 +28,7 @@ namespace BTCPayServer.Plugins.PointOfSale
public override void Execute(IServiceCollection services)
{
- services.AddUIExtension("header-nav", "PointOfSale/NavExtension");
+ services.AddUIExtension("header-nav", "/Plugins/PointOfSale/Views/NavExtension.cshtml");
services.AddSingleton<AppBaseType, PointOfSaleAppType>();
base.Execute(services);
}
diff --git a/BTCPayServer/Plugins/PointOfSale/Views/NavExtension.cshtml b/BTCPayServer/Plugins/PointOfSale/Views/NavExtension.cshtml
new file mode 100644
index 0000000..9ba841c
--- /dev/null
+++ b/BTCPayServer/Plugins/PointOfSale/Views/NavExtension.cshtml
@@ -0,0 +1,44 @@
+@using BTCPayServer.Client
+@using Microsoft.AspNetCore.Mvc.TagHelpers
+@using BTCPayServer.Views.Apps
+@using BTCPayServer.Plugins.PointOfSale
+@using BTCPayServer.Services.Apps
+@inject AppService AppService
+@model BTCPayServer.Components.MainNav.MainNavViewModel
+@{
+ var store = Context.GetStoreData();
+}
+
+@if (store != null)
+{
+ var appType = AppService.GetAppType(PointOfSaleAppType.AppType)!;
+ var apps = Model.Apps.Where(app => app.AppType == appType.Type).ToList();
+ <li class="nav-item" permission="@Policies.CanModifyStoreSettings">
+ <a layout-menu-item="CreateApp-@appType.Type" asp-area="" asp-controller="UIApps" asp-action="CreateApp" asp-route-storeId="@store.Id" asp-route-appType="@appType.Type">
+ <vc:icon symbol="nav-pointofsale" />
+ <span text-translate="true">Point of Sale</span>
+ </a>
+ </li>
+ @if (apps.Any())
+ {
+ <li layout-menu-item="CreateApp-@appType.Type" not-permission="@Policies.CanModifyStoreSettings" permission="@Policies.CanViewStoreSettings">
+ <span class="nav-link">
+ <vc:icon symbol="nav-pointofsale" />
+ <span text-translate="true">Point of Sale</span>
+ </span>
+ </li>
+ }
+ @foreach (var app in apps)
+ {
+ <li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
+ <a layout-menu-item="@nameof(PointOfSalePlugin)-@app.Id" asp-area="" asp-controller="UIPointOfSale" asp-action="UpdatePointOfSale" asp-route-appId="@app.Id">
+ <span>@app.AppName</span>
+ </a>
+ </li>
+ <li class="nav-item nav-item-sub" not-permission="@Policies.CanViewStoreSettings">
+ <a layout-menu-item="@nameof(PointOfSalePlugin)-@app.Id" asp-area="" asp-controller="UIPointOfSale" asp-action="ViewPointOfSale" asp-route-appId="@app.Id">
+ <span>@app.AppName</span>
+ </a>
+ </li>
+ }
+}
diff --git a/BTCPayServer/Plugins/PointOfSale/Views/Public/Cart.cshtml b/BTCPayServer/Plugins/PointOfSale/Views/Public/Cart.cshtml
new file mode 100644
index 0000000..db9e0ad
--- /dev/null
+++ b/BTCPayServer/Plugins/PointOfSale/Views/Public/Cart.cshtml
@@ -0,0 +1,278 @@
+@using BTCPayServer.Services
+@using Microsoft.AspNetCore.Mvc.TagHelpers
+@using Newtonsoft.Json.Linq
+@using BTCPayServer.Client
+@using BTCPayServer.Abstractions.TagHelpers
+@using BTCPayServer.Client.Models
+@inject DisplayFormatter DisplayFormatter
+@inject BTCPayServer.Security.ContentSecurityPolicies Csp
+@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
+@{
+ Layout = "/Plugins/PointOfSale/Views/Public/_Layout.cshtml";
+ 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(AppItem item)
+ {
+ if (item.PriceType == AppItemPriceType.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 == AppItemPriceType.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="@StringLocalizer["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 == AppItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText
+ : item.BuyButtonText;
+ buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
+ var categories = new JArray(item.Categories ?? new object[] { });
+ <div id="card_@item.Id" 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 == AppItemPriceType.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">
+ @if (item.Inventory > 0)
+ {
+ <span>@StringLocalizer["{0} left", item.Inventory.ToString()]</span>
+ }
+ else
+ {
+ <span text-translate="true">Sold out</span>
+ }
+ </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 != AppItemPriceType.Fixed)
+ {
+ <div class="input-group mb-2">
+ <span class="input-group-text">@Model.CurrencySymbol</span>
+ <input class="form-control" type="number" inputmode="decimal" min="@(item.Price ?? 0)" step="@Model.Step" name="amount" placeholder="@StringLocalizer["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">
+ <span text-translate="true">Powered by</span> <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" text-translate="true">
+ Empty
+ </button>
+ <button id="CartClose" type="button" class="cart-toggle-btn" v-on:click="toggleCart" aria-controls="cart" aria-label="@StringLocalizer["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" text-translate="true">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" inputmode="decimal" placeholder="@StringLocalizer["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" text-translate="true">Discount</th>
+ <th class="align-middle" colspan="3">
+ <div class="d-flex justify-content-end">
+ <div class="input-group input-group-sm w-100px">
+ <input class="form-control hide-number-spin" type="number" inputmode="decimal" min="0" step="1" max="100" id="Discount" v-model.number="discountPercent">
+ <span class="input-group-text">%</span>
+ </div>
+ </div>
+ </th>
+ </tr>
+ <tr v-if="enableTips">
+ <th class="align-middle" text-translate="true">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"
+ inputmode="decimal"
+ 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 v-if="itemsTotalNumeric">
+ <td class="align-middle h6 border-0" text-translate="true">Items total</td>
+ <td class="align-middle h6 border-0 text-end" id="CartItemsTotal">{{ formatCurrency(itemsTotalNumeric, true) }}</td>
+ </tr>
+ <tr v-if="discountNumeric">
+ <td class="align-middle" text-translate="true">Discount</td>
+ <td class="align-middle text-end" id="CartDiscount">
+ <span>{{ formatCurrency(discountNumeric, true) }}</span> <span v-if="discountPercent">({{discountPercent}}%)</span>
+ </td>
+ </tr>
+ <tr v-if="subtotalNumeric">
+ <td class="align-middle h6 border-0" text-translate="true">Subtotal</td>
+ <td class="align-middle h6 border-0 text-end" id="CartAmount">{{ formatCurrency(subtotalNumeric, true) }}</td>
+ </tr>
+ <tr v-if="tipNumeric">
+ <td class="align-middle" text-translate="true">Tip</td>
+ <td class="align-middle text-end" id="CartTip">
+ <span>{{ formatCurrency(tipNumeric, true) }}</span> <span v-if="tipPercent">({{tipPercent}}%)</span>
+ </td>
+ </tr>
+ <tr v-if="taxNumeric">
+ <td class="align-middle" text-translate="true">Taxes</td>
+ <td class="align-middle text-end" id="CartTax">
+ <span>{{ formatCurrency(taxNumeric, true) }}</span> <span v-if="taxPercent">({{taxPercent}}%)</span>
+ </td>
+ </tr>
+ <tr>
+ <td class="align-middle h5 border-0" text-translate="true">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" text-translate="true">Loading...</span>
+ </div>
+ <template v-else text-translate="true">Pay</template>
+ </button>
+ </td>
+ </tr>
+ </table>
+ </form>
+ <p id="CartItems" v-else class="text-muted text-center my-0" text-translate="true">There are no items in your cart yet.</p>
+ </div>
+ </div>
+ </aside>
+ <partial name="/Plugins/PointOfSale/Views/Public/RecentTransactions.cshtml" model="Model"/>
+</div>
diff --git a/BTCPayServer/Plugins/PointOfSale/Views/Public/Light.cshtml b/BTCPayServer/Plugins/PointOfSale/Views/Public/Light.cshtml
new file mode 100644
index 0000000..a51bbbb
--- /dev/null
+++ b/BTCPayServer/Plugins/PointOfSale/Views/Public/Light.cshtml
@@ -0,0 +1,36 @@
+@inject BTCPayServer.Security.ContentSecurityPolicies Csp
+@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
+@{
+ Layout = "/Plugins/PointOfSale/Views/Public/_Layout.cshtml";
+ Csp.UnsafeEval();
+}
+@section PageHeadContent {
+ <link href="~/pos/keypad.css" asp-append-version="true" rel="stylesheet" />
+}
+@section PageFootContent {
+ <script>var 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/keypad.js" asp-append-version="true"></script>
+}
+<div id="PosKeypad" class="public-page-wrap">
+ <partial name="_StatusMessage" />
+ <partial name="_StoreHeader" model="(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title, Model.StoreBranding)" />
+ @await Component.InvokeAsync("UiExtensionPoint", new { location = "pos-header", model = Model })
+ @if (Context.Request.Query.ContainsKey("simple"))
+ {
+ <partial name="/Plugins/PointOfSale/Views/Public/MinimalLight.cshtml" model="Model" />
+ }
+ else
+ {
+ <noscript>
+ <partial name="/Plugins/PointOfSale/Views/Public/MinimalLight.cshtml" model="Model" />
+ </noscript>
+ <partial name="/Plugins/PointOfSale/Views/Public/VueLight.cshtml" model="Model" />
+ }
+ <footer class="store-footer">
+ <a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
+ <span text-translate="true">Powered by</span> <partial name="_StoreFooterLogo" />
+ </a>
+ </footer>
+</div>
diff --git a/BTCPayServer/Plugins/PointOfSale/Views/Public/MinimalLight.cshtml b/BTCPayServer/Plugins/PointOfSale/Views/Public/MinimalLight.cshtml
new file mode 100644
index 0000000..68bef5d
--- /dev/null
+++ b/BTCPayServer/Plugins/PointOfSale/Views/Public/MinimalLight.cshtml
@@ -0,0 +1,11 @@
+<div class="container p-0 l-pos-wrapper my-0 mx-auto">
+ <div class="py-5 px-3">
+ <form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false">
+ <div class="input-group">
+ <span class="input-group-text">@Model.CurrencySymbol</span>
+ <input class="form-control" type="number" step="@Model.Step" name="amount" placeholder="@StringLocalizer["Amount"]">
+ <button class="btn btn-primary" type="submit" text-translate="true">Pay</button>
+ </div>
+ </form>
+ </div>
+</div>
diff --git a/BTCPayServer/Plugins/PointOfSale/Views/Public/Print.cshtml b/BTCPayServer/Plugins/PointOfSale/Views/Public/Print.cshtml
new file mode 100644
index 0000000..8c0b200
--- /dev/null
+++ b/BTCPayServer/Plugins/PointOfSale/Views/Public/Print.cshtml
@@ -0,0 +1,147 @@
+@using BTCPayServer.Abstractions.TagHelpers
+@using BTCPayServer.Client.Models
+@using BTCPayServer.Components.QRCode
+@using BTCPayServer.Payments
+@using BTCPayServer.Payments.Lightning
+@using BTCPayServer.Services
+@using BTCPayServer.Services.Invoices
+@using BTCPayServer.Services.Stores
+@using LNURL
+@using Microsoft.AspNetCore.Mvc.TagHelpers
+@inject StoreRepository StoreRepository
+@inject PaymentMethodHandlerDictionary Handlers
+@inject DisplayFormatter DisplayFormatter
+@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
+@{
+ var store = await StoreRepository.FindStore(Model.StoreId);
+ Layout = "/Plugins/PointOfSale/Views/Public/_Layout.cshtml";
+ Context.Request.Query.TryGetValue("cryptocode", out var cryptoCodeValues);
+ var cryptoCode = cryptoCodeValues.FirstOrDefault() ?? "BTC";
+ var pmi = PaymentTypes.LNURL.GetPaymentMethodId(cryptoCode);
+ var supported = store.GetPaymentMethodConfig<LNURLPaymentMethodConfig>(pmi, Handlers);
+ if (supported is not null && !store.GetEnabledPaymentIds().Contains(pmi))
+ {
+ supported = null;
+ }
+}
+@section PageHeadContent {
+ <link href="~/pos/print.css" asp-append-version="true" rel="stylesheet" />
+}
+
+@if (supported is null)
+{
+ <div class="alert alert-warning text-center sticky-top mb-0 rounded-0" role="alert">
+ LNURL is not enabled on your store, which this print feature relies on.
+ <a asp-controller="UIStores" asp-action="LightningSettings" asp-route-cryptoCode="BTC" asp-route-storeId="@store.Id" class="alert-link p-0">
+ Enable LNURL
+ </a>
+ </div>
+}
+else
+{
+ <div class="alert alert-info alert-dismissible d-flex align-items-center justify-content-center sticky-top mb-0 rounded-0 d-print-none fade show" role="alert">
+ <button type="button" class="btn btn-info me-4 border border-light" onclick="window.print()">
+ <vc:icon symbol="pos-print" /> Print
+ </button>
+ This view is intended for printing only —
+
+ <a asp-route-viewType="static" class="alert-link">Regular version</a>
+ </div>
+}
+<div id="PosPrint" class="public-page-wrap">
+ <partial name="_StatusMessage" />
+ <partial name="_StoreHeader" model="(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title, Model.StoreBranding)" />
+ @if (!string.IsNullOrEmpty(Model.Description))
+ {
+ <div class="lead text-center">@Safe.Raw(Model.Description)</div>
+ }
+ <main>
+ @if (supported is not null)
+ {
+ if (Model.ShowCustomAmount)
+ {
+ Model.Items = Model.Items.Concat([
+ new AppItem
+ {
+ Description = "Create invoice to pay custom amount",
+ Title = "Custom amount",
+ BuyButtonText = Model.CustomButtonText,
+ PriceType = AppItemPriceType.Topup
+ }
+ ]).ToArray();
+ }
+ }
+
+ <div class="posItems">
+ @for (var x = 0; x < Model.Items.Length; x++)
+ {
+ var item = Model.Items[x];
+ var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
+ if (item.PriceType == AppItemPriceType.Fixed && item.Price == 0) continue;
+ <div class="d-flex flex-wrap">
+ <div class="tile card w-100" data-id="@x">
+ <div class="card-body pt-0 d-flex flex-column gap-2">
+ <h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
+ <div class="d-flex gap-2 align-items-center">
+ <span class="fw-semibold">
+ @switch (item.PriceType)
+ {
+ case AppItemPriceType.Topup:
+ <span>Any amount</span>
+ break;
+ case AppItemPriceType.Minimum:
+ <span>@formatted minimum</span>
+ break;
+ case AppItemPriceType.Fixed:
+ @formatted
+ break;
+ default:
+ throw new ArgumentOutOfRangeException();
+ }
+ </span>
+ @if (item.Inventory.HasValue)
+ {
+ <span class="badge text-bg-light">
+ @if (item.Inventory > 0)
+ {
+ <span>@ViewLocalizer["{0} left", item.Inventory.ToString()]</span>
+ }
+ else
+ {
+ <span text-translate="true">Sold out</span>
+ }
+ </span>
+ }
+ </div>
+ @if (!string.IsNullOrWhiteSpace(item.Description))
+ {
+ <p class="card-text">@Safe.Raw(item.Description)</p>
+ }
+ </div>
+ @if (item.Inventory is null or > 0)
+ {
+ if (supported != null)
+ {
+ var lnurlEndpoint = new Uri(Url.Action("GetLNURLForApp", "UILNURL", new
+ {
+ cryptoCode = cryptoCode,
+ appid = Model.AppId,
+ ItemCode = item.Id
+ }, Context.Request.Scheme, Context.Request.Host.ToString()));
+ var lnUrl = LNURL.EncodeUri(lnurlEndpoint, "payRequest", supported.UseBech32Scheme);
+ <a href="@lnUrl" rel="noreferrer noopener" class="card-img-bottom">
+ <vc:qr-code data="@lnUrl.ToString().ToUpperInvariant()" />
+ </a>
+ }
+ }
+ </div>
+ </div>
+ }
+ </div>
+ </main>
+ <footer class="store-footer d-print-none">
+ <a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
+ <span text-translate="true">Powered by</span> <partial name="_StoreFooterLogo" />
+ </a>
+ </footer>
+</div>
diff --git a/BTCPayServer/Plugins/PointOfSale/Views/Public/RecentTransactions.cshtml b/BTCPayServer/Plugins/PointOfSale/Views/Public/RecentTransactions.cshtml
new file mode 100644
index 0000000..8e21471
--- /dev/null
+++ b/BTCPayServer/Plugins/PointOfSale/Views/Public/RecentTransactions.cshtml
@@ -0,0 +1,36 @@
+@using BTCPayServer.Client
+@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
+
+<div class="modal" tabindex="-1" id="RecentTransactions" ref="RecentTransactions" data-bs-backdrop="static" data-url="@Url.Action("RecentTransactions", "UIPointOfSale", new { appId = Model.AppId })" permission="@Policies.CanViewInvoices">
+ <div class="modal-dialog modal-dialog-centered">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title">Recent Transactions</h5>
+ <button type="button" class="btn btn-link px-3 py-0" aria-label="Refresh" v-on:click="loadRecentTransactions" :disabled="recentTransactionsLoading" id="RecentTransactionsRefresh">
+ <vc:icon symbol="actions-refresh"/>
+ <span v-if="recentTransactionsLoading" class="visually-hidden" text-translate="true">Loading...</span>
+ </button>
+ <button type="button" class="btn-close py-3" aria-label="@StringLocalizer["Close"]" v-on:click="hideRecentTransactions">
+ <vc:icon symbol="close"/>
+ </button>
+ </div>
+ <div class="modal-body">
+ <div v-if="recentTransactions.length" class="list-group list-group-flush">
+ <a v-for="t in recentTransactions" :key="t.id" :href="t.url" class="list-group-item list-group-item-action d-flex align-items-center gap-3 pe-1 py-3">
+ <div class="d-flex align-items-baseline justify-content-between flex-wrap flex-grow-1 gap-2">
+ <span class="flex-grow-1">{{displayDate(t.date)}}</span>
+ <span class="flex-grow-1 text-end">{{t.price}}</span>
+ <div class="badge-container">
+ <span class="badge" :class="`badge-${t.status.toLowerCase()}`">{{t.status}}</span>
+ </div>
+ </div>
+ <vc:icon symbol="caret-right" />
+ </a>
+ </div>
+ <p v-else-if="recentTransactionsLoading" class="text-muted my-0">Loading...</p>
+ <p v-else class="text-muted my-0">No transactions, yet.</p>
+ </div>
+ </div>
+ </div>
+</div>
+
diff --git a/BTCPayServer/Plugins/PointOfSale/Views/Public/Static.cshtml b/BTCPayServer/Plugins/PointOfSale/Views/Public/Static.cshtml
new file mode 100644
index 0000000..d15fb67
--- /dev/null
+++ b/BTCPayServer/Plugins/PointOfSale/Views/Public/Static.cshtml
@@ -0,0 +1,120 @@
+@using BTCPayServer.Client.Models
+@using BTCPayServer.Services
+@using Microsoft.AspNetCore.Mvc.TagHelpers
+@inject DisplayFormatter DisplayFormatter
+@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
+@{
+ Layout = "/Plugins/PointOfSale/Views/Public/_Layout.cshtml";
+}
+@functions {
+ private string GetItemPriceFormatted(AppItem item)
+ {
+ if (item.PriceType == AppItemPriceType.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 == AppItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
+ }
+}
+
+<div id="PosStatic" class="public-page-wrap">
+ <partial name="_StoreHeader" model="(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title, Model.StoreBranding)" />
+ <main>
+ <partial name="_StatusMessage" />
+ @if (!string.IsNullOrEmpty(Model.Description))
+ {
+ <div class="lead">@Safe.Raw(Model.Description)</div>
+ }
+ <div class="row row-cols row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4" id="PosItems">
+ @for (var x = 0; x < Model.Items.Length; x++)
+ {
+ var item = Model.Items[x];
+ var formatted = GetItemPriceFormatted(item);
+ var inStock = item.Inventory is null or > 0;
+ var buttonText = string.IsNullOrEmpty(item.BuyButtonText)
+ ? item.PriceType == AppItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText
+ : item.BuyButtonText;
+ buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
+
+ <div id="card_@item.Id" class="col posItem posItem--displayed@(x == 0 ? " posItem--first" : null)@(x == Model.Items.Length - 1 && !Model.ShowCustomAmount ? " posItem--last" : null)">
+ <div class="tile card" data-id="@x">
+ @if (!string.IsNullOrWhiteSpace(item.Image))
+ {
+ <img class="card-img-top" src="@item.Image" alt="@Safe.Raw(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 == AppItemPriceType.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">
+ @if (item.Inventory > 0)
+ {
+ <span>@ViewLocalizer["{0} left", item.Inventory.ToString()]</span>
+ }
+ else
+ {
+ <span text-translate="true">Sold out</span>
+ }
+ </span>
+ }
+ </div>
+ @if (!string.IsNullOrWhiteSpace(item.Description))
+ {
+ <p class="card-text">@Safe.Raw(item.Description)</p>
+ }
+ </div>
+ <div class="card-footer">
+ @if (inStock)
+ {
+ <form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" autocomplete="off">
+ <input type="hidden" name="choiceKey" value="@item.Id" />
+ @if (item.PriceType == AppItemPriceType.Minimum)
+ {
+ <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="@StringLocalizer["Amount"]" value="@item.Price" required>
+ </div>
+ }
+ <button class="btn btn-primary w-100" type="submit">@Safe.Raw(buttonText)</button>
+ </form>
+ }
+ </div>
+ </div>
+ </div>
+ }
+ @if (Model.ShowCustomAmount)
+ {
+ <div id="card_custom_amount" class="col posItem posItem--displayed posItem--last@(Model.Items.Length == 0 ? " posItem--first" : null)">
+ <div class="card h-100 px-0">
+ <div class="card-body p-3 d-flex flex-column gap-2 mb-auto">
+ <h5 class="card-title" text-translate="true">Custom Amount</h5>
+ <p class="card-text" text-translate="true">Create invoice to pay custom amount</p>
+ </div>
+ <div class="card-footer bg-transparent border-0 pb-3">
+ <form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" autocomplete="off">
+ <div class="input-group mb-2">
+ <span class="input-group-text">@Model.CurrencySymbol</span>
+ <input class="form-control" type="number" min="0" step="@Model.Step" name="amount" placeholder="@StringLocalizer["Amount"]" required>
+ </div>
+ <button class="btn btn-primary w-100" type="submit">@Safe.Raw(Model.CustomButtonText ?? Model.ButtonText)</button>
+ </form>
+ </div>
+ </div>
+ </div>
+ }
+ </div>
+ </main>
+ <footer class="store-footer">
+ <a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
+ <span text-translate="true">Powered by</span> <partial name="_StoreFooterLogo" />
+ </a>
+ </footer>
+</div>
diff --git a/BTCPayServer/Plugins/PointOfSale/Views/Public/VueLight.cshtml b/BTCPayServer/Plugins/PointOfSale/Views/Public/VueLight.cshtml
new file mode 100644
index 0000000..65988b1
--- /dev/null
+++ b/BTCPayServer/Plugins/PointOfSale/Views/Public/VueLight.cshtml
@@ -0,0 +1,171 @@
+@using BTCPayServer.Plugins.PointOfSale.Models
+@using BTCPayServer.Services
+@using Microsoft.AspNetCore.Mvc.TagHelpers
+@using Newtonsoft.Json.Linq
+@using BTCPayServer.Client
+@using BTCPayServer.Client.Models
+@inject DisplayFormatter DisplayFormatter
+@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
+@functions {
+ private string GetItemPriceFormatted(AppItem item)
+ {
+ if (item.PriceType == AppItemPriceType.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 == AppItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
+ }
+}
+<form id="app" method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 my-auto" v-cloak>
+ <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">
+ <div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto">
+ <div class="fw-semibold text-muted" id="Currency">{{currencyCode}}</div>
+ <div class="fw-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }" id="Amount">{{ formatCurrency(lastAmount, false) }}</div>
+ <div class="fw-bold lh-sm" ref="total" v-if="totalNumeric !== parseFloat(lastAmount)" v-bind:style="{ fontSize: `${fontSize/3}px` }" id="Total">Total: {{ formatCurrency(totalNumeric, true) }}</div>
+ <div class="text-muted text-center mt-2" id="Calculation">{{ calculation }}</div>
+ </div>
+ <div id="ModeTabs" class="tab-content mb-n2" v-if="showDiscount || enableTips">
+ <div id="Mode-Discount" class="tab-pane fade px-2" :class="{ show: mode === 'discount', active: mode === 'discount' }" role="tabpanel" aria-labelledby="ModeTablist-Discount" v-if="showDiscount">
+ <div class="h4 fw-semibold text-muted text-center" id="Discount">
+ <span class="h3 text-body me-1">{{discountPercent || 0}}%</span> discount
+ </div>
+ </div>
+ <div id="Mode-Tip" class="tab-pane fade px-2" :class="{ show: mode === 'tip', active: mode === 'tip' }" role="tabpanel" aria-labelledby="ModeTablist-Tip" v-if="enableTips">
+ <div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-2">
+ <template v-if="customTipPercentages">
+ <button
+ id="Tip-Custom"
+ type="button"
+ class="btcpay-pill"
+ :class="{ active: !tipPercent }"
+ v-on:click.prevent="tipPercent = null">
+ <template v-if="tip && tip > 0">{{formatCurrency(tip, true)}}</template>
+ <template v-else>Custom</template>
+ </button>
+ <button
+ v-for="percentage in customTipPercentages"
+ type="button"
+ class="btcpay-pill"
+ :class="{ active: tipPercent == percentage }"
+ :id="`Tip-${percentage}`"
+ v-on:click.prevent="tipPercentage(percentage)">
+ {{ percentage }}%
+ </button>
+ </template>
+ <div v-else class="h5 fw-semibold text-muted text-center">
+ Amount<template v-if="tip">: {{formatCurrency(tip, true)}}</template>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div id="ModeTablist" class="nav btcpay-pills align-items-center justify-content-center mb-n2 pb-1" role="tablist" v-if="modes.length > 1">
+ <template v-for="m in modes" :key="m.value">
+ <input :id="`ModeTablist-${m.type}`" name="mode" :value="m.type" type="radio" role="tab" data-bs-toggle="pill" :data-bs-target="`#Mode-${m.type}`" :disabled="m.type != 'amounts' && summary.priceTaxExcluded == 0" :aria-controls="`Mode-${m.type}`" :aria-selected="mode === m.type" :checked="mode === m.type" v-on:click="mode = m.type">
+ <label :for="`ModeTablist-${m.type}`">{{ m.title }}</label>
+ </template>
+ </div>
+ <div class="keypad">
+ <button v-for="k in keys" :key="k" :disabled="k === '+' && mode !== 'amounts'" v-on:click.prevent="keyPressed(k)" v-on:dblclick.prevent="doubleClick(k)" type="button" class="btn btn-secondary btn-lg" :data-key="k">
+ <template v-if="k === 'C'"><vc:icon symbol="keypad-clear"/></template>
+ <template v-else-if="k === '+'"><vc:icon symbol="keypad-plus"/></template>
+ <template v-else>{{ k }}</template>
+ </button>
+ </div>
+ <button class="btn btn-lg btn-primary mx-3" type="submit" :disabled="payButtonLoading || (totalNumeric <= 0 && posOrder.itemLines.length === 0)" id="pay-button">
+ <div v-if="payButtonLoading" class="spinner-border spinner-border-sm" id="pay-button-spinner" role="status">
+ <span class="visually-hidden" text-translate="true">Loading...</span>
+ </div>
+ <template v-else>
+ <span text-translate="true">Charge</span>
+ </template>
+ </button>
+ <partial name="/Plugins/PointOfSale/Views/Public/RecentTransactions.cshtml" model="Model"/>
+ <button type="button" class="btn btn-light rounded-circle" data-bs-toggle="modal" data-bs-target="#RecentTransactions" id="RecentTransactionsToggle" permission="@Policies.CanViewInvoices">
+ <vc:icon symbol="nav-transactions"/>
+ </button>
+ <button type="button" class="btn btn-light rounded-circle" data-bs-toggle="offcanvas" data-bs-target="#ItemsListOffcanvas" id="ItemsListToggle" aria-controls="ItemsList" v-if="showItems">
+ <vc:icon symbol="nav-products"/>
+ </button>
+ <div class="offcanvas offcanvas-end" data-bs-backdrop="static" tabindex="-1" id="ItemsListOffcanvas" aria-labelledby="ItemsListToggle" v-if="showItems">
+ <div class="offcanvas-header justify-content-between flex-wrap p-3">
+ <h5 class="offcanvas-title" id="offcanvasExampleLabel">Products</h5>
+ <button type="button" class="btn btn-sm rounded-pill" :class="{ 'btn-primary': cart.length > 0, 'btn-outline-secondary': cart.length === 0}" data-bs-dismiss="offcanvas" v-text="cart.length > 0 ? 'Apply' : 'Close'"></button>
+ @if (Model.ShowSearch)
+ {
+ <div class="w-100 mt-3">
+ <input id="SearchTerm" class="form-control rounded-pill" placeholder="@StringLocalizer["Search…"]" v-model="searchTerm" v-if="showSearch">
+ </div>
+ }
+ @if (Model.ShowCategories)
+ {
+ <div id="Categories" ref="categories" v-if="showCategories && allCategories" class="w-100 mt-3 btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-2" :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>
+ }
+ </div>
+ <div class="offcanvas-body">
+ <div ref="posItems" 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 displayed = item.PriceType == AppItemPriceType.Fixed && inStock ? "true" : "false";
+ var categories = new JArray(item.Categories ?? new object[] { });
+ <div class="posItem p-3" :class="{ 'posItem--inStock': inStock(@index), 'posItem--displayed': @displayed }" data-index="@index" data-search="@Safe.RawEncode(item.Title + " " + item.Description)" data-categories='@Safe.Json(categories)' v-show="@displayed">
+ <div class="d-flex align-items-start w-100 gap-3">
+ @if (!string.IsNullOrWhiteSpace(item.Image))
+ {
+ <div class="img d-none d-sm-block">
+ <img src="@item.Image" alt="@item.Title" asp-append-version="true" />
+ </div>
+ }
+ <div class="d-flex flex-column gap-2">
+ <h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
+ <div class="d-flex gap-2 align-items-center">
+ @if (item.PriceType == AppItemPriceType.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">
+ @if (item.Inventory > 0)
+ {
+ <span>@ViewLocalizer["{0} left", item.Inventory.ToString()]</span>
+ }
+ else
+ {
+ <span text-translate="true">Sold out</span>
+ }
+ </span>
+ }
+ </div>
+ </div>
+ <div class="d-flex align-items-center gap-2 ms-auto quantities">
+ <button type="button" v-on:click="updateQuantity(`@Safe.Raw(item.Id)`, -1, true)" class="btn btn-minus" :disabled="getQuantity(`@Safe.Raw(item.Id)`) <= 0">
+ <span><vc:icon symbol="minus" /></span>
+ </button>
+ <div class="quantity text-center fs-5" style="width:2rem">{{ getQuantity(`@Safe.Raw(item.Id)`) }}</div>
+ <button type="button" v-on:click="updateQuantity(`@Safe.Raw(item.Id)`, +1, true)" class="btn btn-plus" :disabled="!inStock(@index)">
+ <span><vc:icon symbol="plus" /></span>
+ </button>
+ </div>
+ </div>
+ </div>
+ }
+ </div>
+ </div>
+ </div>
+</form>
diff --git a/BTCPayServer/Plugins/PointOfSale/Views/Public/_Layout.cshtml b/BTCPayServer/Plugins/PointOfSale/Views/Public/_Layout.cshtml
new file mode 100644
index 0000000..2a0fec9
--- /dev/null
+++ b/BTCPayServer/Plugins/PointOfSale/Views/Public/_Layout.cshtml
@@ -0,0 +1,52 @@
+@using Microsoft.AspNetCore.Hosting
+@using Newtonsoft.Json.Linq
+@using System.IO
+@using BTCPayServer.Services
+@inject IWebHostEnvironment WebHostEnvironment
+@inject SettingsRepository SettingsRepository
+@inject BTCPayServerEnvironment Env
+
+@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
+@{
+ ViewData["Title"] = string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title;
+ ViewData["StoreBranding"] = Model.StoreBranding;
+ Layout = null;
+
+ async Task<string> GetDynamicManifest(string title)
+ {
+ var settings = await SettingsRepository.GetSettingAsync<ServerSettings>() ?? new ServerSettings();
+ var manifest = WebHostEnvironment.WebRootFileProvider.GetFileInfo("manifest.json");
+ if (!manifest.Exists)
+ {
+ return null;
+ }
+ using var reader = new StreamReader(manifest.CreateReadStream());
+ var jObject = JObject.Parse(await reader.ReadToEndAsync());
+ var serverName = string.IsNullOrWhiteSpace(settings.ServerName) ? "BTCPay Server" : settings.ServerName;
+ jObject["short_name"] = title;
+ jObject["name"] = $"{serverName}: {title}";
+ foreach (var jToken in jObject["icons"]!)
+ {
+ var icon = (JObject)jToken;
+ icon["src"] = $"{Context.Request.GetAbsoluteRoot()}/{icon["src"]}";
+ }
+ return $"data:application/manifest+json,{Safe.Json(jObject)}";
+ }
+}
+<!DOCTYPE html>
+<html class="h-100" lang="@Model.HtmlLang" @(Env.IsDeveloping ? " data-devenv" : "") id="POS-@Model.AppId">
+<head>
+ <partial name="LayoutHead" />
+ <meta name="apple-mobile-web-app-capable" content="yes">
+ <link rel="apple-touch-startup-image" href="~/img/splash.png">
+ <link rel="manifest" href="@(await GetDynamicManifest(ViewData["Title"]!.ToString()))">
+ <link href="~/pos/common.css" asp-append-version="true" rel="stylesheet" />
+ @this.Safe.Meta(Model.HtmlMetaTags)
+ @await RenderSectionAsync("PageHeadContent", false)
+</head>
+<body class="min-vh-100">
+ @RenderBody()
+ <partial name="LayoutFoot"/>
+ @await RenderSectionAsync("PageFootContent", false)
+</body>
+</html>
diff --git a/BTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml b/BTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml
new file mode 100644
index 0000000..24ef300
--- /dev/null
+++ b/BTCPayServer/Plugins/PointOfSale/Views/UpdatePointOfSale.cshtml
@@ -0,0 +1,376 @@
+@using BTCPayServer.Views.Apps
+@using BTCPayServer.Client
+@using BTCPayServer.Plugins.PointOfSale
+@using BTCPayServer.Forms
+@using BTCPayServer.TagHelpers
+@using Microsoft.AspNetCore.Mvc.TagHelpers
+@inject FormDataService FormDataService
+@inject BTCPayServer.Security.ContentSecurityPolicies Csp
+@model BTCPayServer.Plugins.PointOfSale.Models.UpdatePointOfSaleViewModel
+@{
+ Layout = "_Layout";
+ ViewData.SetLayoutModel(new($"{nameof(PointOfSalePlugin)}-{Model.Id}",StringLocalizer["Update Point of Sale"]));
+ Csp.UnsafeEval();
+ var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId);
+ var posPath = Url.Action("ViewPointOfSale", "UIPointOfSale", new { appId = Model.Id });
+ var posUrl = Url.ActionAbsolute(this.Context.Request, "ViewPointOfSale", "UIPointOfSale", new { appId = Model.Id }).AbsoluteUri;
+}
+
+@section PageHeadContent {
+ <link href="~/vendor/highlightjs/default.min.css" rel="stylesheet" asp-append-version="true">
+ <link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
+ <link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true" />
+}
+
+@section PageFootContent {
+ <partial name="_ValidationScriptsPartial" />
+ <script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
+ <script src="~/pos/admin.js" asp-append-version="true"></script>
+}
+
+<form method="post" permissioned="@Policies.CanModifyStoreSettings">
+ <div class="sticky-header">
+ <h2>@ViewData["Title"]</h2>
+ <div>
+ <button id="page-primary" type="submit" class="btn btn-primary order-sm-1">Save</button>
+ <a class="btn btn-secondary" asp-action="ListInvoices" asp-controller="UIInvoice" asp-route-storeId="@Model.StoreId" asp-route-searchterm="@Model.SearchTerm" text-translate="true">Invoices</a>
+ @if (Model.Archived)
+ {
+ <button type="submit" class="btn btn-outline-secondary" name="Archived" value="False" text-translate="true">Unarchive</button>
+ }
+ else
+ {
+ <div class="btn-group" role="group" aria-label="View Point of Sale">
+ <a class="btn btn-secondary" asp-controller="UIPointOfSale" asp-action="ViewPointOfSale" asp-route-appId="@Model.Id" id="ViewApp" target="_blank" text-translate="true">View</a>
+ <button type="button" class="btn btn-secondary px-3 d-inline-flex align-items-center" data-bs-toggle="modal" data-bs-target="#OpenPosModal">
+ <vc:icon symbol="qr-code" />
+ </button>
+ </div>
+ }
+ </div>
+ </div>
+
+ <partial name="_StatusMessage" />
+
+ <input type="hidden" asp-for="StoreId" />
+ <input type="hidden" asp-for="Archived" />
+
+ @if (!ViewContext.ModelState.IsValid)
+ {
+ <div asp-validation-summary="All" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
+ }
+ <div class="row" style="max-width:540px;">
+ <div class="col-sm-6">
+ <div class="form-group">
+ <label asp-for="AppName" class="form-label" data-required></label>
+ <input asp-for="AppName" class="form-control" required />
+ <span asp-validation-for="AppName" class="text-danger"></span>
+ </div>
+ </div>
+ <div class="col-sm-6">
+ <div class="form-group">
+ <label asp-for="Title" class="form-label" data-required></label>
+ <input asp-for="Title" class="form-control" required />
+ <span asp-validation-for="Title" class="text-danger"></span>
+ </div>
+ </div>
+ <div class="form-group">
+ <label asp-for="DefaultView" class="form-label" data-required text-translate="true">Choose Point of Sale Style</label>
+ <div class="btcpay-list-select">
+ @foreach (var type in Enum.GetValues<PosViewType>())
+ {
+ <input type="radio" asp-for="DefaultView" value="@type" id="DefaultView_@type">
+ <label for="DefaultView_@type" class="btcpay-list-select-item">
+ <vc:icon symbol="pos-@type.ToString().ToLowerInvariant()" />
+ @typeof(PosViewType).DisplayName(type.ToString())
+ </label>
+ }
+ </div>
+ </div>
+ <div class="form-group mb-0">
+ <label asp-for="Currency" class="form-label"></label>
+ <input asp-for="Currency" class="form-control w-auto" currency-selection />
+ <div class="form-text">@StringLocalizer["Uses the store's default currency ({0}) if empty.", Model.StoreDefaultCurrency]</div>
+ <span asp-validation-for="Currency" class="text-danger"></span>
+ </div>
+ </div>
+
+ <div id="description" class="row mt-4">
+ <div class="col-xxl-constrain">
+ <div class="form-group mb-0">
+ <label asp-for="Description" class="form-label"></label>
+ <textarea asp-for="Description" rows="10" cols="40" class="form-control richtext"></textarea>
+ <span asp-validation-for="Description" class="text-danger"></span>
+ </div>
+ </div>
+ </div>
+ <div id="products" class="row">
+ <div class="col-xxl-constrain">
+ <partial name="TemplateEditor" model="@(nameof(Model.Template), Model.Template, "Products", Model.Currency ?? Model.StoreDefaultCurrency)" />
+ </div>
+ </div>
+ <div class="row mt-5">
+ <div class="col-sm-10 col-md-9 col-xl-7 col-xxl-6">
+ <h3 class="mb-4" text-translate="true">Checkout</h3>
+ <fieldset>
+ <div class="form-group" id="button-price-text">
+ <label asp-for="ButtonText" class="form-label" data-required></label>
+ <input asp-for="ButtonText" class="form-control" required />
+ <span asp-validation-for="ButtonText" class="text-danger"></span>
+ </div>
+ <div class="form-group">
+ <label asp-for="FormId" class="form-label"></label>
+ <select asp-for="FormId" class="form-select w-auto" asp-items="@checkoutFormOptions"></select>
+ <span asp-validation-for="FormId" class="text-danger"></span>
+ </div>
+ <div class="form-group d-flex align-items-center">
+ <input asp-for="DisableZeroAmountInvoice" type="checkbox" class="btcpay-toggle me-3"/>
+ <label asp-for="DisableZeroAmountInvoice" class="form-check-label"></label>
+ </div>
+ </fieldset>
+ <fieldset id="keypad-display" class="mt-2">
+ <legend class="h5 mb-3 fw-semibold" text-translate="true">Keypad</legend>
+ <div class="form-group d-flex align-items-center pt-2">
+ <input asp-for="ShowItems" type="checkbox" class="btcpay-toggle me-3" />
+ <label asp-for="ShowItems" class="form-label mb-0"></label>
+ <span asp-validation-for="ShowItems" class="text-danger"></span>
+ </div>
+ </fieldset>
+ <fieldset id="cart-display" class="mt-2">
+ <legend class="h5 mb-3 fw-semibold">Cart</legend>
+ <div class="form-group d-flex align-items-center pt-2">
+ <input asp-for="ShowSearch" type="checkbox" class="btcpay-toggle me-3" />
+ <label asp-for="ShowSearch" class="form-check-label"></label>
+ <span asp-validation-for="ShowSearch" class="text-danger"></span>
+ </div>
+ <div class="form-group d-flex align-items-center">
+ <input asp-for="ShowCategories" type="checkbox" class="btcpay-toggle me-3" />
+ <label asp-for="ShowCategories" class="form-check-label"></label>
+ <span asp-validation-for="ShowCategories" class="text-danger"></span>
+ </div>
+ </fieldset>
+ <fieldset id="tips" class="mt-2">
+ <legend class="h5 mb-3 fw-semibold" text-translate="true">Tips</legend>
+ <div class="form-group d-flex align-items-center pt-2">
+ <input asp-for="EnableTips" type="checkbox" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#CustomTipsSettings" aria-expanded="@Model.EnableTips" aria-controls="CustomTipsSettings" />
+ <label asp-for="EnableTips" class="form-check-label"></label>
+ <span asp-validation-for="EnableTips" class="text-danger"></span>
+ </div>
+ <div class="collapse @(Model.EnableTips ? "show" : "")" id="CustomTipsSettings">
+ <div class="form-group">
+ <label asp-for="CustomTipText" class="form-label" data-required></label>
+ <input asp-for="CustomTipText" class="form-control" required />
+ <span asp-validation-for="CustomTipText" class="text-danger"></span>
+ </div>
+ <div class="form-group">
+ <label asp-for="CustomTipPercentages" class="form-label"></label>
+ <input asp-for="CustomTipPercentages" class="form-control" />
+ <span asp-validation-for="CustomTipPercentages" class="text-danger"></span>
+ </div>
+ </div>
+ </fieldset>
+
+ <fieldset id="taxes" class="mt-2">
+ <legend class="h5 mb-3 fw-semibold" text-translate="true">Taxes</legend>
+ <div class="form-group">
+ <label asp-for="DefaultTaxRate" class="form-label"></label>
+ <div class="input-group">
+ <input inputmode="decimal" asp-for="DefaultTaxRate" class="form-control" />
+ <span class="input-group-text">%</span>
+ </div>
+ <div class="form-text" text-translate="true">This rate can also be overridden per item.</div>
+ <span asp-validation-for="DefaultTaxRate" class="text-danger"></span>
+ </div>
+ </fieldset>
+
+ <fieldset id="discounts" class="mt-2">
+ <legend class="h5 mb-3 fw-semibold" text-translate="true">Discounts</legend>
+ <div class="form-group d-flex align-items-center">
+ <input asp-for="ShowDiscount" type="checkbox" class="btcpay-toggle me-3" />
+ <div>
+ <label asp-for="ShowDiscount" class="form-check-label"></label>
+ <div class="text-muted" text-translate="true">Not recommended for customer self-checkout.</div>
+ </div>
+ <span asp-validation-for="ShowDiscount" class="text-danger"></span>
+ </div>
+ </fieldset>
+ <fieldset id="custom-payments" class="mt-2">
+ <legend class="h5 mb-3 fw-semibold" text-translate="true">Custom Payments</legend>
+ <div class="form-group mb-4 d-flex align-items-center">
+ <input asp-for="ShowCustomAmount" type="checkbox" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#CustomAmountSettings" aria-expanded="@Model.ShowCustomAmount" aria-controls="CustomAmountSettings"/>
+ <label asp-for="ShowCustomAmount" class="form-check-label"></label>
+ <span asp-validation-for="ShowCustomAmount" class="text-danger"></span>
+ </div>
+ <div class="collapse @(Model.ShowCustomAmount ? "show" : "")" id="CustomAmountSettings">
+ <div class="form-group">
+ <label asp-for="CustomButtonText" class="form-label" data-required></label>
+ <input asp-for="CustomButtonText" class="form-control" required />
+ <span asp-validation-for="CustomButtonText" class="text-danger"></span>
+ </div>
+ </div>
+ </fieldset>
+ </div>
+ </div>
+ <div class="row" style="margin-top:2.25rem;">
+ <div class="col-xl-8 col-xxl-constrain">
+ <h3 class="mb-2">Additional Options</h3>
+ <div class="form-group">
+ <div class="accordion" id="additional">
+
+ <div class="accordion-item">
+ <h2 class="accordion-header" id="additional-htmlheader-header">
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-htmlheader" aria-expanded="false" aria-controls="additional-htmlheader">
+ <span text-translate="true">HTML Headers</span>
+ <vc:icon symbol="caret-down" />
+ </button>
+ </h2>
+ <div id="additional-htmlheader" class="accordion-collapse collapse" aria-labelledby="additional-htmlheader-header">
+ <div class="accordion-body">
+ <div class="form-group">
+ <label asp-for="HtmlLang" class="form-label"></label>
+ <input asp-for="HtmlLang" class="form-control"/>
+ <span asp-validation-for="HtmlLang" class="text-danger"></span>
+ </div>
+ <div class="form-group">
+ <label asp-for="HtmlMetaTags" class="form-label"></label>
+ <textarea asp-for="HtmlMetaTags" rows="5" cols="40" class="form-control"
+ placeholder='<meta name="description" content="Your description">
+<meta name="keywords" content="keyword1, keyword2, keyword3">
+<meta name="author" content="John Doe">
+Please insert valid HTML here. Only meta tags accepted.'>
+ </textarea>
+ <span asp-validation-for="HtmlMetaTags" class="text-danger"></span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div class="accordion-item">
+ <h2 class="accordion-header" id="additional-embed-payment-button-header">
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-embed-payment-button" aria-expanded="false" aria-controls="additional-embed-payment-button">
+ <span text-translate="true">Embed a payment button linking to POS item</span>
+ <vc:icon symbol="caret-down" />
+ </button>
+ </h2>
+ <div id="additional-embed-payment-button" class="accordion-collapse collapse" aria-labelledby="additional-embed-payment-button-header">
+ <div class="accordion-body">
+ <p text-translate="true">You can host point of sale buttons in an external website with the following code.</p>
+ @if (Model.Example1 != null)
+ {
+ <span text-translate="true">For anything with a custom amount</span>
+ <pre class="p-3">@Model.Example1</pre>
+ }
+ @if (Model.Example2 != null)
+ {
+ <span text-translate="true">For a specific item of your template</span>
+ <pre class="p-3">@Model.Example2</pre>
+ }
+ </div>
+ </div>
+ </div>
+ <div class="accordion-item">
+ <h2 class="accordion-header" id="additional-embed-pos-iframe-header">
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-embed-pos-iframe" aria-expanded="false" aria-controls="additional-embed-pos-iframe">
+ <span text-translate="true">Embed Point of Sale via iframe</span>
+ <vc:icon symbol="caret-down" />
+ </button>
+ </h2>
+ <div id="additional-embed-pos-iframe" class="accordion-collapse collapse" aria-labelledby="additional-embed-pos-iframe-header">
+ <div class="accordion-body">
+ <span text-translate="true">You can embed this POS via an iframe.</span>
+ @{
+ var iframe = $"<iframe src='{Url.Action("ViewPointOfSale", "UIPointOfSale", new { appId = Model.Id }, Context.Request.Scheme)}' style='max-width: 100%; border: 0;'></iframe>";
+ }
+ <pre class="p-3">@iframe</pre>
+ </div>
+ </div>
+ </div>
+ <div class="accordion-item">
+ <h2 class="accordion-header" id="additional-redirect-header">
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-redirect" aria-expanded="false" aria-controls="additional-redirect">
+ <span text-translate="true">Redirects</span>
+ <vc:icon symbol="caret-down" />
+ </button>
+ </h2>
+ <div id="additional-redirect" class="accordion-collapse collapse" aria-labelledby="additional-redirect-header">
+ <div class="accordion-body">
+ <div class="form-group">
+ <label asp-for="RedirectUrl" class="form-label"></label>
+ <input asp-for="RedirectUrl" class="form-control" />
+ <span asp-validation-for="RedirectUrl" class="text-danger"></span>
+ </div>
+ <div class="form-group">
+ <label asp-for="RedirectAutomatically" class="form-label"></label>
+ <select asp-for="RedirectAutomatically" asp-items="Model.RedirectAutomaticallySelectList" class="form-select"></select>
+ <span asp-validation-for="RedirectAutomatically" class="text-danger"></span>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="accordion-item">
+ <h2 class="accordion-header" id="additional-notification-header">
+ <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-notification" aria-expanded="false" aria-controls="additional-notification">
+ <span text-translate="true">Notification URL Callbacks</span>
+ <vc:icon symbol="caret-down" />
+ </button>
+ </h2>
+ <div id="additional-notification" class="accordion-collapse collapse" aria-labelledby="additional-notification-header">
+ <div class="accordion-body">
+ <div class="form-group">
+ <label asp-for="NotificationUrl" class="form-label"></label>
+ <input asp-for="NotificationUrl" class="form-control" />
+ <span asp-validation-for="NotificationUrl" class="text-danger"></span>
+ </div>
+ <p html-translate="true">A <code>POST</code> callback will be sent to the specified <code>notificationUrl</code> (for on-chain transactions when there are sufficient confirmations):</p>
+ <pre class="p-3">@Model.ExampleCallback</pre>
+ <p html-translate="true"><strong>Never</strong> trust anything but <code>id</code>, <strong>ignore</strong> the other fields completely, an attacker can spoof those, they are present only for backward compatibility reason:</p>
+ <ul>
+ <li html-translate="true">Send a <code>GET</code> request to <code>https://btcpay.example.com/invoices/{invoiceId}</code> with <code>Content-Type: application/json; Authorization: Basic YourLegacyAPIkey"</code>, Legacy API key can be created with Access Tokens in Store settings</li>
+ <li html-translate="true">Verify that the <code>orderId</code> is from your backend, that the <code>price</code> is correct and that <code>status</code> is <code>settled</code></li>
+ <li text-translate="true">You can then ship your order</li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+</form>
+
+<div class="d-grid d-sm-flex flex-wrap gap-3 mt-3">
+ <form method="post" asp-controller="UIApps" asp-action="ToggleArchive" asp-route-appId="@Model.Id">
+ <button type="submit" class="w-100 btn btn-outline-secondary" id="btn-archive-toggle" permission="@Policies.CanModifyStoreSettings">
+ @if (Model.Archived)
+ {
+ <span class="text-nowrap" text-translate="true">Unarchive this app</span>
+ }
+ else
+ {
+ <span class="text-nowrap" data-bs-toggle="tooltip" title="@StringLocalizer["Archive this app so that it does not appear in the apps list by default"]" text-translate="true">Archive this app</span>
+ }
+ </button>
+ </form>
+ <a id="DeleteApp" class="btn btn-outline-danger" asp-controller="UIApps" asp-action="DeleteApp" asp-route-appId="@Model.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="@StringLocalizer["The app <strong>{0}</strong> and its settings will be permanently deleted.", Html.Encode(Model.AppName)]" data-confirm-input="@StringLocalizer["DELETE"]" permission="@Policies.CanModifyStoreSettings" text-translate="true">Delete this app</a>
+</div>
+
+<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Delete app"], StringLocalizer["This app will be removed from this store."], StringLocalizer["Delete"]))" permission="@Policies.CanModifyStoreSettings" />
+
+<div class="modal fade" id="OpenPosModal" tabindex="-1" aria-labelledby="ConfirmTitle" aria-hidden="true">
+ <div class="modal-dialog" role="document">
+ <div class="modal-content">
+ <div class="modal-header">
+ <h5 class="modal-title" text-translate="true">Scan the QR code to open the Point of Sale</h5>
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
+ <vc:icon symbol="close"/>
+ </button>
+ </div>
+ <div class="modal-body pt-0">
+ <component type="typeof(BTCPayServer.Blazor.PosLoginCode)" render-mode="ServerPrerendered"
+ param-Users="@Model.StoreUsers"
+ param-PosPath="@posPath"
+ param-PosUrl="@posUrl"/>
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/BTCPayServer/Views/Shared/PointOfSale/NavExtension.cshtml b/BTCPayServer/Views/Shared/PointOfSale/NavExtension.cshtml
deleted file mode 100644
index 9ba841c..0000000
--- a/BTCPayServer/Views/Shared/PointOfSale/NavExtension.cshtml
+++ /dev/null
@@ -1,44 +0,0 @@
-@using BTCPayServer.Client
-@using Microsoft.AspNetCore.Mvc.TagHelpers
-@using BTCPayServer.Views.Apps
-@using BTCPayServer.Plugins.PointOfSale
-@using BTCPayServer.Services.Apps
-@inject AppService AppService
-@model BTCPayServer.Components.MainNav.MainNavViewModel
-@{
- var store = Context.GetStoreData();
-}
-
-@if (store != null)
-{
- var appType = AppService.GetAppType(PointOfSaleAppType.AppType)!;
- var apps = Model.Apps.Where(app => app.AppType == appType.Type).ToList();
- <li class="nav-item" permission="@Policies.CanModifyStoreSettings">
- <a layout-menu-item="CreateApp-@appType.Type" asp-area="" asp-controller="UIApps" asp-action="CreateApp" asp-route-storeId="@store.Id" asp-route-appType="@appType.Type">
- <vc:icon symbol="nav-pointofsale" />
- <span text-translate="true">Point of Sale</span>
- </a>
- </li>
- @if (apps.Any())
- {
- <li layout-menu-item="CreateApp-@appType.Type" not-permission="@Policies.CanModifyStoreSettings" permission="@Policies.CanViewStoreSettings">
- <span class="nav-link">
- <vc:icon symbol="nav-pointofsale" />
- <span text-translate="true">Point of Sale</span>
- </span>
- </li>
- }
- @foreach (var app in apps)
- {
- <li class="nav-item nav-item-sub" permission="@Policies.CanViewStoreSettings">
- <a layout-menu-item="@nameof(PointOfSalePlugin)-@app.Id" asp-area="" asp-controller="UIPointOfSale" asp-action="UpdatePointOfSale" asp-route-appId="@app.Id">
- <span>@app.AppName</span>
- </a>
- </li>
- <li class="nav-item nav-item-sub" not-permission="@Policies.CanViewStoreSettings">
- <a layout-menu-item="@nameof(PointOfSalePlugin)-@app.Id" asp-area="" asp-controller="UIPointOfSale" asp-action="ViewPointOfSale" asp-route-appId="@app.Id">
- <span>@app.AppName</span>
- </a>
- </li>
- }
-}
diff --git a/BTCPayServer/Views/Shared/PointOfSale/Public/Cart.cshtml b/BTCPayServer/Views/Shared/PointOfSale/Public/Cart.cshtml
deleted file mode 100644
index f022f94..0000000
--- a/BTCPayServer/Views/Shared/PointOfSale/Public/Cart.cshtml
+++ /dev/null
@@ -1,278 +0,0 @@
-@using BTCPayServer.Services
-@using Microsoft.AspNetCore.Mvc.TagHelpers
-@using Newtonsoft.Json.Linq
-@using BTCPayServer.Client
-@using BTCPayServer.Abstractions.TagHelpers
-@using BTCPayServer.Client.Models
-@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(AppItem item)
- {
- if (item.PriceType == AppItemPriceType.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 == AppItemPriceType.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="@StringLocalizer["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 == AppItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText
- : item.BuyButtonText;
- buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
- var categories = new JArray(item.Categories ?? new object[] { });
- <div id="card_@item.Id" 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 == AppItemPriceType.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">
- @if (item.Inventory > 0)
- {
- <span>@StringLocalizer["{0} left", item.Inventory.ToString()]</span>
- }
- else
- {
- <span text-translate="true">Sold out</span>
- }
- </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 != AppItemPriceType.Fixed)
- {
- <div class="input-group mb-2">
- <span class="input-group-text">@Model.CurrencySymbol</span>
- <input class="form-control" type="number" inputmode="decimal" min="@(item.Price ?? 0)" step="@Model.Step" name="amount" placeholder="@StringLocalizer["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">
- <span text-translate="true">Powered by</span> <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" text-translate="true">
- Empty
- </button>
- <button id="CartClose" type="button" class="cart-toggle-btn" v-on:click="toggleCart" aria-controls="cart" aria-label="@StringLocalizer["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" text-translate="true">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" inputmode="decimal" placeholder="@StringLocalizer["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" text-translate="true">Discount</th>
- <th class="align-middle" colspan="3">
- <div class="d-flex justify-content-end">
- <div class="input-group input-group-sm w-100px">
- <input class="form-control hide-number-spin" type="number" inputmode="decimal" min="0" step="1" max="100" id="Discount" v-model.number="discountPercent">
- <span class="input-group-text">%</span>
- </div>
- </div>
- </th>
- </tr>
- <tr v-if="enableTips">
- <th class="align-middle" text-translate="true">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"
- inputmode="decimal"
- 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 v-if="itemsTotalNumeric">
- <td class="align-middle h6 border-0" text-translate="true">Items total</td>
- <td class="align-middle h6 border-0 text-end" id="CartItemsTotal">{{ formatCurrency(itemsTotalNumeric, true) }}</td>
- </tr>
- <tr v-if="discountNumeric">
- <td class="align-middle" text-translate="true">Discount</td>
- <td class="align-middle text-end" id="CartDiscount">
- <span>{{ formatCurrency(discountNumeric, true) }}</span> <span v-if="discountPercent">({{discountPercent}}%)</span>
- </td>
- </tr>
- <tr v-if="subtotalNumeric">
- <td class="align-middle h6 border-0" text-translate="true">Subtotal</td>
- <td class="align-middle h6 border-0 text-end" id="CartAmount">{{ formatCurrency(subtotalNumeric, true) }}</td>
- </tr>
- <tr v-if="tipNumeric">
- <td class="align-middle" text-translate="true">Tip</td>
- <td class="align-middle text-end" id="CartTip">
- <span>{{ formatCurrency(tipNumeric, true) }}</span> <span v-if="tipPercent">({{tipPercent}}%)</span>
- </td>
- </tr>
- <tr v-if="taxNumeric">
- <td class="align-middle" text-translate="true">Taxes</td>
- <td class="align-middle text-end" id="CartTax">
- <span>{{ formatCurrency(taxNumeric, true) }}</span> <span v-if="taxPercent">({{taxPercent}}%)</span>
- </td>
- </tr>
- <tr>
- <td class="align-middle h5 border-0" text-translate="true">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" text-translate="true">Loading...</span>
- </div>
- <template v-else text-translate="true">Pay</template>
- </button>
- </td>
- </tr>
- </table>
- </form>
- <p id="CartItems" v-else class="text-muted text-center my-0" text-translate="true">There are no items in your cart yet.</p>
- </div>
- </div>
- </aside>
- <partial name="PointOfSale/Public/RecentTransactions" model="Model"/>
-</div>
diff --git a/BTCPayServer/Views/Shared/PointOfSale/Public/Light.cshtml b/BTCPayServer/Views/Shared/PointOfSale/Public/Light.cshtml
deleted file mode 100644
index c67cd7b..0000000
--- a/BTCPayServer/Views/Shared/PointOfSale/Public/Light.cshtml
+++ /dev/null
@@ -1,36 +0,0 @@
-@inject BTCPayServer.Security.ContentSecurityPolicies Csp
-@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
-@{
- Layout = "PointOfSale/Public/_Layout";
- Csp.UnsafeEval();
-}
-@section PageHeadContent {
- <link href="~/pos/keypad.css" asp-append-version="true" rel="stylesheet" />
-}
-@section PageFootContent {
- <script>var 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/keypad.js" asp-append-version="true"></script>
-}
-<div id="PosKeypad" class="public-page-wrap">
- <partial name="_StatusMessage" />
- <partial name="_StoreHeader" model="(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title, Model.StoreBranding)" />
- @await Component.InvokeAsync("UiExtensionPoint", new { location = "pos-header", model = Model })
- @if (Context.Request.Query.ContainsKey("simple"))
- {
- <partial name="PointOfSale/Public/MinimalLight" model="Model" />
- }
- else
- {
- <noscript>
- <partial name="PointOfSale/Public/MinimalLight" model="Model" />
- </noscript>
- <partial name="PointOfSale/Public/VueLight" model="Model" />
- }
- <footer class="store-footer">
- <a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
- <span text-translate="true">Powered by</span> <partial name="_StoreFooterLogo" />
- </a>
- </footer>
-</div>
diff --git a/BTCPayServer/Views/Shared/PointOfSale/Public/MinimalLight.cshtml b/BTCPayServer/Views/Shared/PointOfSale/Public/MinimalLight.cshtml
deleted file mode 100644
index 68bef5d..0000000
--- a/BTCPayServer/Views/Shared/PointOfSale/Public/MinimalLight.cshtml
+++ /dev/null
@@ -1,11 +0,0 @@
-<div class="container p-0 l-pos-wrapper my-0 mx-auto">
- <div class="py-5 px-3">
- <form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false">
- <div class="input-group">
- <span class="input-group-text">@Model.CurrencySymbol</span>
- <input class="form-control" type="number" step="@Model.Step" name="amount" placeholder="@StringLocalizer["Amount"]">
- <button class="btn btn-primary" type="submit" text-translate="true">Pay</button>
- </div>
- </form>
- </div>
-</div>
diff --git a/BTCPayServer/Views/Shared/PointOfSale/Public/Print.cshtml b/BTCPayServer/Views/Shared/PointOfSale/Public/Print.cshtml
deleted file mode 100644
index d26eff0..0000000
--- a/BTCPayServer/Views/Shared/PointOfSale/Public/Print.cshtml
+++ /dev/null
@@ -1,147 +0,0 @@
-@using BTCPayServer.Abstractions.TagHelpers
-@using BTCPayServer.Client.Models
-@using BTCPayServer.Components.QRCode
-@using BTCPayServer.Payments
-@using BTCPayServer.Payments.Lightning
-@using BTCPayServer.Services
-@using BTCPayServer.Services.Invoices
-@using BTCPayServer.Services.Stores
-@using LNURL
-@using Microsoft.AspNetCore.Mvc.TagHelpers
-@inject StoreRepository StoreRepository
-@inject PaymentMethodHandlerDictionary Handlers
-@inject DisplayFormatter DisplayFormatter
-@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
-@{
- var store = await StoreRepository.FindStore(Model.StoreId);
- Layout = "PointOfSale/Public/_Layout";
- Context.Request.Query.TryGetValue("cryptocode", out var cryptoCodeValues);
- var cryptoCode = cryptoCodeValues.FirstOrDefault() ?? "BTC";
- var pmi = PaymentTypes.LNURL.GetPaymentMethodId(cryptoCode);
- var supported = store.GetPaymentMethodConfig<LNURLPaymentMethodConfig>(pmi, Handlers);
- if (supported is not null && !store.GetEnabledPaymentIds().Contains(pmi))
- {
- supported = null;
- }
-}
-@section PageHeadContent {
- <link href="~/pos/print.css" asp-append-version="true" rel="stylesheet" />
-}
-
-@if (supported is null)
-{
- <div class="alert alert-warning text-center sticky-top mb-0 rounded-0" role="alert">
- LNURL is not enabled on your store, which this print feature relies on.
- <a asp-controller="UIStores" asp-action="LightningSettings" asp-route-cryptoCode="BTC" asp-route-storeId="@store.Id" class="alert-link p-0">
- Enable LNURL
- </a>
- </div>
-}
-else
-{
- <div class="alert alert-info alert-dismissible d-flex align-items-center justify-content-center sticky-top mb-0 rounded-0 d-print-none fade show" role="alert">
- <button type="button" class="btn btn-info me-4 border border-light" onclick="window.print()">
- <vc:icon symbol="pos-print" /> Print
- </button>
- This view is intended for printing only —
-
- <a asp-route-viewType="static" class="alert-link">Regular version</a>
- </div>
-}
-<div id="PosPrint" class="public-page-wrap">
- <partial name="_StatusMessage" />
- <partial name="_StoreHeader" model="(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title, Model.StoreBranding)" />
- @if (!string.IsNullOrEmpty(Model.Description))
- {
- <div class="lead text-center">@Safe.Raw(Model.Description)</div>
- }
- <main>
- @if (supported is not null)
- {
- if (Model.ShowCustomAmount)
- {
- Model.Items = Model.Items.Concat([
- new AppItem
- {
- Description = "Create invoice to pay custom amount",
- Title = "Custom amount",
- BuyButtonText = Model.CustomButtonText,
- PriceType = AppItemPriceType.Topup
- }
- ]).ToArray();
- }
- }
-
- <div class="posItems">
- @for (var x = 0; x < Model.Items.Length; x++)
- {
- var item = Model.Items[x];
- var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
- if (item.PriceType == AppItemPriceType.Fixed && item.Price == 0) continue;
- <div class="d-flex flex-wrap">
- <div class="tile card w-100" data-id="@x">
- <div class="card-body pt-0 d-flex flex-column gap-2">
- <h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
- <div class="d-flex gap-2 align-items-center">
- <span class="fw-semibold">
- @switch (item.PriceType)
- {
- case AppItemPriceType.Topup:
- <span>Any amount</span>
- break;
- case AppItemPriceType.Minimum:
- <span>@formatted minimum</span>
- break;
- case AppItemPriceType.Fixed:
- @formatted
- break;
- default:
- throw new ArgumentOutOfRangeException();
- }
- </span>
- @if (item.Inventory.HasValue)
- {
- <span class="badge text-bg-light">
- @if (item.Inventory > 0)
- {
- <span>@ViewLocalizer["{0} left", item.Inventory.ToString()]</span>
- }
- else
- {
- <span text-translate="true">Sold out</span>
- }
- </span>
- }
- </div>
- @if (!string.IsNullOrWhiteSpace(item.Description))
- {
- <p class="card-text">@Safe.Raw(item.Description)</p>
- }
- </div>
- @if (item.Inventory is null or > 0)
- {
- if (supported != null)
- {
- var lnurlEndpoint = new Uri(Url.Action("GetLNURLForApp", "UILNURL", new
- {
- cryptoCode = cryptoCode,
- appid = Model.AppId,
- ItemCode = item.Id
- }, Context.Request.Scheme, Context.Request.Host.ToString()));
- var lnUrl = LNURL.EncodeUri(lnurlEndpoint, "payRequest", supported.UseBech32Scheme);
- <a href="@lnUrl" rel="noreferrer noopener" class="card-img-bottom">
- <vc:qr-code data="@lnUrl.ToString().ToUpperInvariant()" />
- </a>
- }
- }
- </div>
- </div>
- }
- </div>
- </main>
- <footer class="store-footer d-print-none">
- <a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
- <span text-translate="true">Powered by</span> <partial name="_StoreFooterLogo" />
- </a>
- </footer>
-</div>
diff --git a/BTCPayServer/Views/Shared/PointOfSale/Public/RecentTransactions.cshtml b/BTCPayServer/Views/Shared/PointOfSale/Public/RecentTransactions.cshtml
deleted file mode 100644
index 8e21471..0000000
--- a/BTCPayServer/Views/Shared/PointOfSale/Public/RecentTransactions.cshtml
+++ /dev/null
@@ -1,36 +0,0 @@
-@using BTCPayServer.Client
-@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
-
-<div class="modal" tabindex="-1" id="RecentTransactions" ref="RecentTransactions" data-bs-backdrop="static" data-url="@Url.Action("RecentTransactions", "UIPointOfSale", new { appId = Model.AppId })" permission="@Policies.CanViewInvoices">
- <div class="modal-dialog modal-dialog-centered">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title">Recent Transactions</h5>
- <button type="button" class="btn btn-link px-3 py-0" aria-label="Refresh" v-on:click="loadRecentTransactions" :disabled="recentTransactionsLoading" id="RecentTransactionsRefresh">
- <vc:icon symbol="actions-refresh"/>
- <span v-if="recentTransactionsLoading" class="visually-hidden" text-translate="true">Loading...</span>
- </button>
- <button type="button" class="btn-close py-3" aria-label="@StringLocalizer["Close"]" v-on:click="hideRecentTransactions">
- <vc:icon symbol="close"/>
- </button>
- </div>
- <div class="modal-body">
- <div v-if="recentTransactions.length" class="list-group list-group-flush">
- <a v-for="t in recentTransactions" :key="t.id" :href="t.url" class="list-group-item list-group-item-action d-flex align-items-center gap-3 pe-1 py-3">
- <div class="d-flex align-items-baseline justify-content-between flex-wrap flex-grow-1 gap-2">
- <span class="flex-grow-1">{{displayDate(t.date)}}</span>
- <span class="flex-grow-1 text-end">{{t.price}}</span>
- <div class="badge-container">
- <span class="badge" :class="`badge-${t.status.toLowerCase()}`">{{t.status}}</span>
- </div>
- </div>
- <vc:icon symbol="caret-right" />
- </a>
- </div>
- <p v-else-if="recentTransactionsLoading" class="text-muted my-0">Loading...</p>
- <p v-else class="text-muted my-0">No transactions, yet.</p>
- </div>
- </div>
- </div>
-</div>
-
diff --git a/BTCPayServer/Views/Shared/PointOfSale/Public/Static.cshtml b/BTCPayServer/Views/Shared/PointOfSale/Public/Static.cshtml
deleted file mode 100644
index f95c243..0000000
--- a/BTCPayServer/Views/Shared/PointOfSale/Public/Static.cshtml
+++ /dev/null
@@ -1,120 +0,0 @@
-@using BTCPayServer.Client.Models
-@using BTCPayServer.Services
-@using Microsoft.AspNetCore.Mvc.TagHelpers
-@inject DisplayFormatter DisplayFormatter
-@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
-@{
- Layout = "PointOfSale/Public/_Layout";
-}
-@functions {
- private string GetItemPriceFormatted(AppItem item)
- {
- if (item.PriceType == AppItemPriceType.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 == AppItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
- }
-}
-
-<div id="PosStatic" class="public-page-wrap">
- <partial name="_StoreHeader" model="(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title, Model.StoreBranding)" />
- <main>
- <partial name="_StatusMessage" />
- @if (!string.IsNullOrEmpty(Model.Description))
- {
- <div class="lead">@Safe.Raw(Model.Description)</div>
- }
- <div class="row row-cols row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4" id="PosItems">
- @for (var x = 0; x < Model.Items.Length; x++)
- {
- var item = Model.Items[x];
- var formatted = GetItemPriceFormatted(item);
- var inStock = item.Inventory is null or > 0;
- var buttonText = string.IsNullOrEmpty(item.BuyButtonText)
- ? item.PriceType == AppItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText
- : item.BuyButtonText;
- buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
-
- <div id="card_@item.Id" class="col posItem posItem--displayed@(x == 0 ? " posItem--first" : null)@(x == Model.Items.Length - 1 && !Model.ShowCustomAmount ? " posItem--last" : null)">
- <div class="tile card" data-id="@x">
- @if (!string.IsNullOrWhiteSpace(item.Image))
- {
- <img class="card-img-top" src="@item.Image" alt="@Safe.Raw(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 == AppItemPriceType.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">
- @if (item.Inventory > 0)
- {
- <span>@ViewLocalizer["{0} left", item.Inventory.ToString()]</span>
- }
- else
- {
- <span text-translate="true">Sold out</span>
- }
- </span>
- }
- </div>
- @if (!string.IsNullOrWhiteSpace(item.Description))
- {
- <p class="card-text">@Safe.Raw(item.Description)</p>
- }
- </div>
- <div class="card-footer">
- @if (inStock)
- {
- <form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" autocomplete="off">
- <input type="hidden" name="choiceKey" value="@item.Id" />
- @if (item.PriceType == AppItemPriceType.Minimum)
- {
- <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="@StringLocalizer["Amount"]" value="@item.Price" required>
- </div>
- }
- <button class="btn btn-primary w-100" type="submit">@Safe.Raw(buttonText)</button>
- </form>
- }
- </div>
- </div>
- </div>
- }
- @if (Model.ShowCustomAmount)
- {
- <div id="card_custom_amount" class="col posItem posItem--displayed posItem--last@(Model.Items.Length == 0 ? " posItem--first" : null)">
- <div class="card h-100 px-0">
- <div class="card-body p-3 d-flex flex-column gap-2 mb-auto">
- <h5 class="card-title" text-translate="true">Custom Amount</h5>
- <p class="card-text" text-translate="true">Create invoice to pay custom amount</p>
- </div>
- <div class="card-footer bg-transparent border-0 pb-3">
- <form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" autocomplete="off">
- <div class="input-group mb-2">
- <span class="input-group-text">@Model.CurrencySymbol</span>
- <input class="form-control" type="number" min="0" step="@Model.Step" name="amount" placeholder="@StringLocalizer["Amount"]" required>
- </div>
- <button class="btn btn-primary w-100" type="submit">@Safe.Raw(Model.CustomButtonText ?? Model.ButtonText)</button>
- </form>
- </div>
- </div>
- </div>
- }
- </div>
- </main>
- <footer class="store-footer">
- <a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
- <span text-translate="true">Powered by</span> <partial name="_StoreFooterLogo" />
- </a>
- </footer>
-</div>
diff --git a/BTCPayServer/Views/Shared/PointOfSale/Public/VueLight.cshtml b/BTCPayServer/Views/Shared/PointOfSale/Public/VueLight.cshtml
deleted file mode 100644
index 066fa6c..0000000
--- a/BTCPayServer/Views/Shared/PointOfSale/Public/VueLight.cshtml
+++ /dev/null
@@ -1,171 +0,0 @@
-@using BTCPayServer.Plugins.PointOfSale.Models
-@using BTCPayServer.Services
-@using Microsoft.AspNetCore.Mvc.TagHelpers
-@using Newtonsoft.Json.Linq
-@using BTCPayServer.Client
-@using BTCPayServer.Client.Models
-@inject DisplayFormatter DisplayFormatter
-@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
-@functions {
- private string GetItemPriceFormatted(AppItem item)
- {
- if (item.PriceType == AppItemPriceType.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 == AppItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
- }
-}
-<form id="app" method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 my-auto" v-cloak>
- <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">
- <div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto">
- <div class="fw-semibold text-muted" id="Currency">{{currencyCode}}</div>
- <div class="fw-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }" id="Amount">{{ formatCurrency(lastAmount, false) }}</div>
- <div class="fw-bold lh-sm" ref="total" v-if="totalNumeric !== parseFloat(lastAmount)" v-bind:style="{ fontSize: `${fontSize/3}px` }" id="Total">Total: {{ formatCurrency(totalNumeric, true) }}</div>
- <div class="text-muted text-center mt-2" id="Calculation">{{ calculation }}</div>
- </div>
- <div id="ModeTabs" class="tab-content mb-n2" v-if="showDiscount || enableTips">
- <div id="Mode-Discount" class="tab-pane fade px-2" :class="{ show: mode === 'discount', active: mode === 'discount' }" role="tabpanel" aria-labelledby="ModeTablist-Discount" v-if="showDiscount">
- <div class="h4 fw-semibold text-muted text-center" id="Discount">
- <span class="h3 text-body me-1">{{discountPercent || 0}}%</span> discount
- </div>
- </div>
- <div id="Mode-Tip" class="tab-pane fade px-2" :class="{ show: mode === 'tip', active: mode === 'tip' }" role="tabpanel" aria-labelledby="ModeTablist-Tip" v-if="enableTips">
- <div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-2">
- <template v-if="customTipPercentages">
- <button
- id="Tip-Custom"
- type="button"
- class="btcpay-pill"
- :class="{ active: !tipPercent }"
- v-on:click.prevent="tipPercent = null">
- <template v-if="tip && tip > 0">{{formatCurrency(tip, true)}}</template>
- <template v-else>Custom</template>
- </button>
- <button
- v-for="percentage in customTipPercentages"
- type="button"
- class="btcpay-pill"
- :class="{ active: tipPercent == percentage }"
- :id="`Tip-${percentage}`"
- v-on:click.prevent="tipPercentage(percentage)">
- {{ percentage }}%
- </button>
- </template>
- <div v-else class="h5 fw-semibold text-muted text-center">
- Amount<template v-if="tip">: {{formatCurrency(tip, true)}}</template>
- </div>
- </div>
- </div>
- </div>
- <div id="ModeTablist" class="nav btcpay-pills align-items-center justify-content-center mb-n2 pb-1" role="tablist" v-if="modes.length > 1">
- <template v-for="m in modes" :key="m.value">
- <input :id="`ModeTablist-${m.type}`" name="mode" :value="m.type" type="radio" role="tab" data-bs-toggle="pill" :data-bs-target="`#Mode-${m.type}`" :disabled="m.type != 'amounts' && summary.priceTaxExcluded == 0" :aria-controls="`Mode-${m.type}`" :aria-selected="mode === m.type" :checked="mode === m.type" v-on:click="mode = m.type">
- <label :for="`ModeTablist-${m.type}`">{{ m.title }}</label>
- </template>
- </div>
- <div class="keypad">
- <button v-for="k in keys" :key="k" :disabled="k === '+' && mode !== 'amounts'" v-on:click.prevent="keyPressed(k)" v-on:dblclick.prevent="doubleClick(k)" type="button" class="btn btn-secondary btn-lg" :data-key="k">
- <template v-if="k === 'C'"><vc:icon symbol="keypad-clear"/></template>
- <template v-else-if="k === '+'"><vc:icon symbol="keypad-plus"/></template>
- <template v-else>{{ k }}</template>
- </button>
- </div>
- <button class="btn btn-lg btn-primary mx-3" type="submit" :disabled="payButtonLoading || (totalNumeric <= 0 && posOrder.itemLines.length === 0)" id="pay-button">
- <div v-if="payButtonLoading" class="spinner-border spinner-border-sm" id="pay-button-spinner" role="status">
- <span class="visually-hidden" text-translate="true">Loading...</span>
- </div>
- <template v-else>
- <span text-translate="true">Charge</span>
- </template>
- </button>
- <partial name="PointOfSale/Public/RecentTransactions" model="Model"/>
- <button type="button" class="btn btn-light rounded-circle" data-bs-toggle="modal" data-bs-target="#RecentTransactions" id="RecentTransactionsToggle" permission="@Policies.CanViewInvoices">
- <vc:icon symbol="nav-transactions"/>
- </button>
- <button type="button" class="btn btn-light rounded-circle" data-bs-toggle="offcanvas" data-bs-target="#ItemsListOffcanvas" id="ItemsListToggle" aria-controls="ItemsList" v-if="showItems">
- <vc:icon symbol="nav-products"/>
- </button>
- <div class="offcanvas offcanvas-end" data-bs-backdrop="static" tabindex="-1" id="ItemsListOffcanvas" aria-labelledby="ItemsListToggle" v-if="showItems">
- <div class="offcanvas-header justify-content-between flex-wrap p-3">
- <h5 class="offcanvas-title" id="offcanvasExampleLabel">Products</h5>
- <button type="button" class="btn btn-sm rounded-pill" :class="{ 'btn-primary': cart.length > 0, 'btn-outline-secondary': cart.length === 0}" data-bs-dismiss="offcanvas" v-text="cart.length > 0 ? 'Apply' : 'Close'"></button>
- @if (Model.ShowSearch)
- {
- <div class="w-100 mt-3">
- <input id="SearchTerm" class="form-control rounded-pill" placeholder="@StringLocalizer["Search…"]" v-model="searchTerm" v-if="showSearch">
- </div>
- }
- @if (Model.ShowCategories)
- {
- <div id="Categories" ref="categories" v-if="showCategories && allCategories" class="w-100 mt-3 btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-2" :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>
- }
- </div>
- <div class="offcanvas-body">
- <div ref="posItems" 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 displayed = item.PriceType == AppItemPriceType.Fixed && inStock ? "true" : "false";
- var categories = new JArray(item.Categories ?? new object[] { });
- <div class="posItem p-3" :class="{ 'posItem--inStock': inStock(@index), 'posItem--displayed': @displayed }" data-index="@index" data-search="@Safe.RawEncode(item.Title + " " + item.Description)" data-categories='@Safe.Json(categories)' v-show="@displayed">
- <div class="d-flex align-items-start w-100 gap-3">
- @if (!string.IsNullOrWhiteSpace(item.Image))
- {
- <div class="img d-none d-sm-block">
- <img src="@item.Image" alt="@item.Title" asp-append-version="true" />
- </div>
- }
- <div class="d-flex flex-column gap-2">
- <h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
- <div class="d-flex gap-2 align-items-center">
- @if (item.PriceType == AppItemPriceType.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">
- @if (item.Inventory > 0)
- {
- <span>@ViewLocalizer["{0} left", item.Inventory.ToString()]</span>
- }
- else
- {
- <span text-translate="true">Sold out</span>
- }
- </span>
- }
- </div>
- </div>
- <div class="d-flex align-items-center gap-2 ms-auto quantities">
- <button type="button" v-on:click="updateQuantity(`@Safe.Raw(item.Id)`, -1, true)" class="btn btn-minus" :disabled="getQuantity(`@Safe.Raw(item.Id)`) <= 0">
- <span><vc:icon symbol="minus" /></span>
- </button>
- <div class="quantity text-center fs-5" style="width:2rem">{{ getQuantity(`@Safe.Raw(item.Id)`) }}</div>
- <button type="button" v-on:click="updateQuantity(`@Safe.Raw(item.Id)`, +1, true)" class="btn btn-plus" :disabled="!inStock(@index)">
- <span><vc:icon symbol="plus" /></span>
- </button>
- </div>
- </div>
- </div>
- }
- </div>
- </div>
- </div>
-</form>
diff --git a/BTCPayServer/Views/Shared/PointOfSale/Public/_Layout.cshtml b/BTCPayServer/Views/Shared/PointOfSale/Public/_Layout.cshtml
deleted file mode 100644
index 2a0fec9..0000000
--- a/BTCPayServer/Views/Shared/PointOfSale/Public/_Layout.cshtml
+++ /dev/null
@@ -1,52 +0,0 @@
-@using Microsoft.AspNetCore.Hosting
-@using Newtonsoft.Json.Linq
-@using System.IO
-@using BTCPayServer.Services
-@inject IWebHostEnvironment WebHostEnvironment
-@inject SettingsRepository SettingsRepository
-@inject BTCPayServerEnvironment Env
-
-@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
-@{
- ViewData["Title"] = string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title;
- ViewData["StoreBranding"] = Model.StoreBranding;
- Layout = null;
-
- async Task<string> GetDynamicManifest(string title)
- {
- var settings = await SettingsRepository.GetSettingAsync<ServerSettings>() ?? new ServerSettings();
- var manifest = WebHostEnvironment.WebRootFileProvider.GetFileInfo("manifest.json");
- if (!manifest.Exists)
- {
- return null;
- }
- using var reader = new StreamReader(manifest.CreateReadStream());
- var jObject = JObject.Parse(await reader.ReadToEndAsync());
- var serverName = string.IsNullOrWhiteSpace(settings.ServerName) ? "BTCPay Server" : settings.ServerName;
- jObject["short_name"] = title;
- jObject["name"] = $"{serverName}: {title}";
- foreach (var jToken in jObject["icons"]!)
- {
- var icon = (JObject)jToken;
- icon["src"] = $"{Context.Request.GetAbsoluteRoot()}/{icon["src"]}";
- }
- return $"data:application/manifest+json,{Safe.Json(jObject)}";
- }
-}
-<!DOCTYPE html>
-<html class="h-100" lang="@Model.HtmlLang" @(Env.IsDeveloping ? " data-devenv" : "") id="POS-@Model.AppId">
-<head>
- <partial name="LayoutHead" />
- <meta name="apple-mobile-web-app-capable" content="yes">
- <link rel="apple-touch-startup-image" href="~/img/splash.png">
- <link rel="manifest" href="@(await GetDynamicManifest(ViewData["Title"]!.ToString()))">
- <link href="~/pos/common.css" asp-append-version="true" rel="stylesheet" />
- @this.Safe.Meta(Model.HtmlMetaTags)
- @await RenderSectionAsync("PageHeadContent", false)
-</head>
-<body class="min-vh-100">
- @RenderBody()
- <partial name="LayoutFoot"/>
- @await RenderSectionAsync("PageFootContent", false)
-</body>
-</html>
diff --git a/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml b/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml
deleted file mode 100644
index 7c2d68e..0000000
--- a/BTCPayServer/Views/Shared/PointOfSale/UpdatePointOfSale.cshtml
+++ /dev/null
@@ -1,375 +0,0 @@
-@using BTCPayServer.Views.Apps
-@using BTCPayServer.Client
-@using BTCPayServer.Plugins.PointOfSale
-@using BTCPayServer.Forms
-@using BTCPayServer.TagHelpers
-@using Microsoft.AspNetCore.Mvc.TagHelpers
-@inject FormDataService FormDataService
-@inject BTCPayServer.Security.ContentSecurityPolicies Csp
-@model BTCPayServer.Plugins.PointOfSale.Models.UpdatePointOfSaleViewModel
-@{
- ViewData.SetLayoutModel(new($"{nameof(PointOfSalePlugin)}-{Model.Id}",StringLocalizer["Update Point of Sale"]));
- Csp.UnsafeEval();
- var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId);
- var posPath = Url.Action("ViewPointOfSale", "UIPointOfSale", new { appId = Model.Id });
- var posUrl = Url.ActionAbsolute(this.Context.Request, "ViewPointOfSale", "UIPointOfSale", new { appId = Model.Id }).AbsoluteUri;
-}
-
-@section PageHeadContent {
- <link href="~/vendor/highlightjs/default.min.css" rel="stylesheet" asp-append-version="true">
- <link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
- <link href="~/vendor/vue-qrcode-reader/vue-qrcode-reader.css" rel="stylesheet" asp-append-version="true" />
-}
-
-@section PageFootContent {
- <partial name="_ValidationScriptsPartial" />
- <script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
- <script src="~/pos/admin.js" asp-append-version="true"></script>
-}
-
-<form method="post" permissioned="@Policies.CanModifyStoreSettings">
- <div class="sticky-header">
- <h2>@ViewData["Title"]</h2>
- <div>
- <button id="page-primary" type="submit" class="btn btn-primary order-sm-1">Save</button>
- <a class="btn btn-secondary" asp-action="ListInvoices" asp-controller="UIInvoice" asp-route-storeId="@Model.StoreId" asp-route-searchterm="@Model.SearchTerm" text-translate="true">Invoices</a>
- @if (Model.Archived)
- {
- <button type="submit" class="btn btn-outline-secondary" name="Archived" value="False" text-translate="true">Unarchive</button>
- }
- else
- {
- <div class="btn-group" role="group" aria-label="View Point of Sale">
- <a class="btn btn-secondary" asp-controller="UIPointOfSale" asp-action="ViewPointOfSale" asp-route-appId="@Model.Id" id="ViewApp" target="_blank" text-translate="true">View</a>
- <button type="button" class="btn btn-secondary px-3 d-inline-flex align-items-center" data-bs-toggle="modal" data-bs-target="#OpenPosModal">
- <vc:icon symbol="qr-code" />
- </button>
- </div>
- }
- </div>
- </div>
-
- <partial name="_StatusMessage" />
-
- <input type="hidden" asp-for="StoreId" />
- <input type="hidden" asp-for="Archived" />
-
- @if (!ViewContext.ModelState.IsValid)
- {
- <div asp-validation-summary="All" class="@(ViewContext.ModelState.ErrorCount.Equals(1) ? "no-marker" : "")"></div>
- }
- <div class="row" style="max-width:540px;">
- <div class="col-sm-6">
- <div class="form-group">
- <label asp-for="AppName" class="form-label" data-required></label>
- <input asp-for="AppName" class="form-control" required />
- <span asp-validation-for="AppName" class="text-danger"></span>
- </div>
- </div>
- <div class="col-sm-6">
- <div class="form-group">
- <label asp-for="Title" class="form-label" data-required></label>
- <input asp-for="Title" class="form-control" required />
- <span asp-validation-for="Title" class="text-danger"></span>
- </div>
- </div>
- <div class="form-group">
- <label asp-for="DefaultView" class="form-label" data-required text-translate="true">Choose Point of Sale Style</label>
- <div class="btcpay-list-select">
- @foreach (var type in Enum.GetValues<PosViewType>())
- {
- <input type="radio" asp-for="DefaultView" value="@type" id="DefaultView_@type">
- <label for="DefaultView_@type" class="btcpay-list-select-item">
- <vc:icon symbol="pos-@type.ToString().ToLowerInvariant()" />
- @typeof(PosViewType).DisplayName(type.ToString())
- </label>
- }
- </div>
- </div>
- <div class="form-group mb-0">
- <label asp-for="Currency" class="form-label"></label>
- <input asp-for="Currency" class="form-control w-auto" currency-selection />
- <div class="form-text">@StringLocalizer["Uses the store's default currency ({0}) if empty.", Model.StoreDefaultCurrency]</div>
- <span asp-validation-for="Currency" class="text-danger"></span>
- </div>
- </div>
-
- <div id="description" class="row mt-4">
- <div class="col-xxl-constrain">
- <div class="form-group mb-0">
- <label asp-for="Description" class="form-label"></label>
- <textarea asp-for="Description" rows="10" cols="40" class="form-control richtext"></textarea>
- <span asp-validation-for="Description" class="text-danger"></span>
- </div>
- </div>
- </div>
- <div id="products" class="row">
- <div class="col-xxl-constrain">
- <partial name="TemplateEditor" model="@(nameof(Model.Template), Model.Template, "Products", Model.Currency ?? Model.StoreDefaultCurrency)" />
- </div>
- </div>
- <div class="row mt-5">
- <div class="col-sm-10 col-md-9 col-xl-7 col-xxl-6">
- <h3 class="mb-4" text-translate="true">Checkout</h3>
- <fieldset>
- <div class="form-group" id="button-price-text">
- <label asp-for="ButtonText" class="form-label" data-required></label>
- <input asp-for="ButtonText" class="form-control" required />
- <span asp-validation-for="ButtonText" class="text-danger"></span>
- </div>
- <div class="form-group">
- <label asp-for="FormId" class="form-label"></label>
- <select asp-for="FormId" class="form-select w-auto" asp-items="@checkoutFormOptions"></select>
- <span asp-validation-for="FormId" class="text-danger"></span>
- </div>
- <div class="form-group d-flex align-items-center">
- <input asp-for="DisableZeroAmountInvoice" type="checkbox" class="btcpay-toggle me-3"/>
- <label asp-for="DisableZeroAmountInvoice" class="form-check-label"></label>
- </div>
- </fieldset>
- <fieldset id="keypad-display" class="mt-2">
- <legend class="h5 mb-3 fw-semibold" text-translate="true">Keypad</legend>
- <div class="form-group d-flex align-items-center pt-2">
- <input asp-for="ShowItems" type="checkbox" class="btcpay-toggle me-3" />
- <label asp-for="ShowItems" class="form-label mb-0"></label>
- <span asp-validation-for="ShowItems" class="text-danger"></span>
- </div>
- </fieldset>
- <fieldset id="cart-display" class="mt-2">
- <legend class="h5 mb-3 fw-semibold">Cart</legend>
- <div class="form-group d-flex align-items-center pt-2">
- <input asp-for="ShowSearch" type="checkbox" class="btcpay-toggle me-3" />
- <label asp-for="ShowSearch" class="form-check-label"></label>
- <span asp-validation-for="ShowSearch" class="text-danger"></span>
- </div>
- <div class="form-group d-flex align-items-center">
- <input asp-for="ShowCategories" type="checkbox" class="btcpay-toggle me-3" />
- <label asp-for="ShowCategories" class="form-check-label"></label>
- <span asp-validation-for="ShowCategories" class="text-danger"></span>
- </div>
- </fieldset>
- <fieldset id="tips" class="mt-2">
- <legend class="h5 mb-3 fw-semibold" text-translate="true">Tips</legend>
- <div class="form-group d-flex align-items-center pt-2">
- <input asp-for="EnableTips" type="checkbox" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#CustomTipsSettings" aria-expanded="@Model.EnableTips" aria-controls="CustomTipsSettings" />
- <label asp-for="EnableTips" class="form-check-label"></label>
- <span asp-validation-for="EnableTips" class="text-danger"></span>
- </div>
- <div class="collapse @(Model.EnableTips ? "show" : "")" id="CustomTipsSettings">
- <div class="form-group">
- <label asp-for="CustomTipText" class="form-label" data-required></label>
- <input asp-for="CustomTipText" class="form-control" required />
- <span asp-validation-for="CustomTipText" class="text-danger"></span>
- </div>
- <div class="form-group">
- <label asp-for="CustomTipPercentages" class="form-label"></label>
- <input asp-for="CustomTipPercentages" class="form-control" />
- <span asp-validation-for="CustomTipPercentages" class="text-danger"></span>
- </div>
- </div>
- </fieldset>
-
- <fieldset id="taxes" class="mt-2">
- <legend class="h5 mb-3 fw-semibold" text-translate="true">Taxes</legend>
- <div class="form-group">
- <label asp-for="DefaultTaxRate" class="form-label"></label>
- <div class="input-group">
- <input inputmode="decimal" asp-for="DefaultTaxRate" class="form-control" />
- <span class="input-group-text">%</span>
- </div>
- <div class="form-text" text-translate="true">This rate can also be overridden per item.</div>
- <span asp-validation-for="DefaultTaxRate" class="text-danger"></span>
- </div>
- </fieldset>
-
- <fieldset id="discounts" class="mt-2">
- <legend class="h5 mb-3 fw-semibold" text-translate="true">Discounts</legend>
- <div class="form-group d-flex align-items-center">
- <input asp-for="ShowDiscount" type="checkbox" class="btcpay-toggle me-3" />
- <div>
- <label asp-for="ShowDiscount" class="form-check-label"></label>
- <div class="text-muted" text-translate="true">Not recommended for customer self-checkout.</div>
- </div>
- <span asp-validation-for="ShowDiscount" class="text-danger"></span>
- </div>
- </fieldset>
- <fieldset id="custom-payments" class="mt-2">
- <legend class="h5 mb-3 fw-semibold" text-translate="true">Custom Payments</legend>
- <div class="form-group mb-4 d-flex align-items-center">
- <input asp-for="ShowCustomAmount" type="checkbox" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#CustomAmountSettings" aria-expanded="@Model.ShowCustomAmount" aria-controls="CustomAmountSettings"/>
- <label asp-for="ShowCustomAmount" class="form-check-label"></label>
- <span asp-validation-for="ShowCustomAmount" class="text-danger"></span>
- </div>
- <div class="collapse @(Model.ShowCustomAmount ? "show" : "")" id="CustomAmountSettings">
- <div class="form-group">
- <label asp-for="CustomButtonText" class="form-label" data-required></label>
- <input asp-for="CustomButtonText" class="form-control" required />
- <span asp-validation-for="CustomButtonText" class="text-danger"></span>
- </div>
- </div>
- </fieldset>
- </div>
- </div>
- <div class="row" style="margin-top:2.25rem;">
- <div class="col-xl-8 col-xxl-constrain">
- <h3 class="mb-2">Additional Options</h3>
- <div class="form-group">
- <div class="accordion" id="additional">
-
- <div class="accordion-item">
- <h2 class="accordion-header" id="additional-htmlheader-header">
- <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-htmlheader" aria-expanded="false" aria-controls="additional-htmlheader">
- <span text-translate="true">HTML Headers</span>
- <vc:icon symbol="caret-down" />
- </button>
- </h2>
- <div id="additional-htmlheader" class="accordion-collapse collapse" aria-labelledby="additional-htmlheader-header">
- <div class="accordion-body">
- <div class="form-group">
- <label asp-for="HtmlLang" class="form-label"></label>
- <input asp-for="HtmlLang" class="form-control"/>
- <span asp-validation-for="HtmlLang" class="text-danger"></span>
- </div>
- <div class="form-group">
- <label asp-for="HtmlMetaTags" class="form-label"></label>
- <textarea asp-for="HtmlMetaTags" rows="5" cols="40" class="form-control"
- placeholder='<meta name="description" content="Your description">
-<meta name="keywords" content="keyword1, keyword2, keyword3">
-<meta name="author" content="John Doe">
-Please insert valid HTML here. Only meta tags accepted.'>
- </textarea>
- <span asp-validation-for="HtmlMetaTags" class="text-danger"></span>
- </div>
- </div>
- </div>
- </div>
-
- <div class="accordion-item">
- <h2 class="accordion-header" id="additional-embed-payment-button-header">
- <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-embed-payment-button" aria-expanded="false" aria-controls="additional-embed-payment-button">
- <span text-translate="true">Embed a payment button linking to POS item</span>
- <vc:icon symbol="caret-down" />
- </button>
- </h2>
- <div id="additional-embed-payment-button" class="accordion-collapse collapse" aria-labelledby="additional-embed-payment-button-header">
- <div class="accordion-body">
- <p text-translate="true">You can host point of sale buttons in an external website with the following code.</p>
- @if (Model.Example1 != null)
- {
- <span text-translate="true">For anything with a custom amount</span>
- <pre class="p-3">@Model.Example1</pre>
- }
- @if (Model.Example2 != null)
- {
- <span text-translate="true">For a specific item of your template</span>
- <pre class="p-3">@Model.Example2</pre>
- }
- </div>
- </div>
- </div>
- <div class="accordion-item">
- <h2 class="accordion-header" id="additional-embed-pos-iframe-header">
- <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-embed-pos-iframe" aria-expanded="false" aria-controls="additional-embed-pos-iframe">
- <span text-translate="true">Embed Point of Sale via iframe</span>
- <vc:icon symbol="caret-down" />
- </button>
- </h2>
- <div id="additional-embed-pos-iframe" class="accordion-collapse collapse" aria-labelledby="additional-embed-pos-iframe-header">
- <div class="accordion-body">
- <span text-translate="true">You can embed this POS via an iframe.</span>
- @{
- var iframe = $"<iframe src='{Url.Action("ViewPointOfSale", "UIPointOfSale", new { appId = Model.Id }, Context.Request.Scheme)}' style='max-width: 100%; border: 0;'></iframe>";
- }
- <pre class="p-3">@iframe</pre>
- </div>
- </div>
- </div>
- <div class="accordion-item">
- <h2 class="accordion-header" id="additional-redirect-header">
- <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-redirect" aria-expanded="false" aria-controls="additional-redirect">
- <span text-translate="true">Redirects</span>
- <vc:icon symbol="caret-down" />
- </button>
- </h2>
- <div id="additional-redirect" class="accordion-collapse collapse" aria-labelledby="additional-redirect-header">
- <div class="accordion-body">
- <div class="form-group">
- <label asp-for="RedirectUrl" class="form-label"></label>
- <input asp-for="RedirectUrl" class="form-control" />
- <span asp-validation-for="RedirectUrl" class="text-danger"></span>
- </div>
- <div class="form-group">
- <label asp-for="RedirectAutomatically" class="form-label"></label>
- <select asp-for="RedirectAutomatically" asp-items="Model.RedirectAutomaticallySelectList" class="form-select"></select>
- <span asp-validation-for="RedirectAutomatically" class="text-danger"></span>
- </div>
- </div>
- </div>
- </div>
- <div class="accordion-item">
- <h2 class="accordion-header" id="additional-notification-header">
- <button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#additional-notification" aria-expanded="false" aria-controls="additional-notification">
- <span text-translate="true">Notification URL Callbacks</span>
- <vc:icon symbol="caret-down" />
- </button>
- </h2>
- <div id="additional-notification" class="accordion-collapse collapse" aria-labelledby="additional-notification-header">
- <div class="accordion-body">
- <div class="form-group">
- <label asp-for="NotificationUrl" class="form-label"></label>
- <input asp-for="NotificationUrl" class="form-control" />
- <span asp-validation-for="NotificationUrl" class="text-danger"></span>
- </div>
- <p html-translate="true">A <code>POST</code> callback will be sent to the specified <code>notificationUrl</code> (for on-chain transactions when there are sufficient confirmations):</p>
- <pre class="p-3">@Model.ExampleCallback</pre>
- <p html-translate="true"><strong>Never</strong> trust anything but <code>id</code>, <strong>ignore</strong> the other fields completely, an attacker can spoof those, they are present only for backward compatibility reason:</p>
- <ul>
- <li html-translate="true">Send a <code>GET</code> request to <code>https://btcpay.example.com/invoices/{invoiceId}</code> with <code>Content-Type: application/json; Authorization: Basic YourLegacyAPIkey"</code>, Legacy API key can be created with Access Tokens in Store settings</li>
- <li html-translate="true">Verify that the <code>orderId</code> is from your backend, that the <code>price</code> is correct and that <code>status</code> is <code>settled</code></li>
- <li text-translate="true">You can then ship your order</li>
- </ul>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
- </div>
-</form>
-
-<div class="d-grid d-sm-flex flex-wrap gap-3 mt-3">
- <form method="post" asp-controller="UIApps" asp-action="ToggleArchive" asp-route-appId="@Model.Id">
- <button type="submit" class="w-100 btn btn-outline-secondary" id="btn-archive-toggle" permission="@Policies.CanModifyStoreSettings">
- @if (Model.Archived)
- {
- <span class="text-nowrap" text-translate="true">Unarchive this app</span>
- }
- else
- {
- <span class="text-nowrap" data-bs-toggle="tooltip" title="@StringLocalizer["Archive this app so that it does not appear in the apps list by default"]" text-translate="true">Archive this app</span>
- }
- </button>
- </form>
- <a id="DeleteApp" class="btn btn-outline-danger" asp-controller="UIApps" asp-action="DeleteApp" asp-route-appId="@Model.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="@StringLocalizer["The app <strong>{0}</strong> and its settings will be permanently deleted.", Html.Encode(Model.AppName)]" data-confirm-input="@StringLocalizer["DELETE"]" permission="@Policies.CanModifyStoreSettings" text-translate="true">Delete this app</a>
-</div>
-
-<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Delete app"], StringLocalizer["This app will be removed from this store."], StringLocalizer["Delete"]))" permission="@Policies.CanModifyStoreSettings" />
-
-<div class="modal fade" id="OpenPosModal" tabindex="-1" aria-labelledby="ConfirmTitle" aria-hidden="true">
- <div class="modal-dialog" role="document">
- <div class="modal-content">
- <div class="modal-header">
- <h5 class="modal-title" text-translate="true">Scan the QR code to open the Point of Sale</h5>
- <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
- <vc:icon symbol="close"/>
- </button>
- </div>
- <div class="modal-body pt-0">
- <component type="typeof(BTCPayServer.Blazor.PosLoginCode)" render-mode="ServerPrerendered"
- param-Users="@Model.StoreUsers"
- param-PosPath="@posPath"
- param-PosUrl="@posUrl"/>
- </div>
- </div>
- </div>
-</div>
Why this scored 15/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.