Skip to content

Commit

Permalink
Blazorify keypad
Browse files Browse the repository at this point in the history
  • Loading branch information
dennisreimann committed Nov 8, 2023
1 parent 5940ae4 commit ae0567a
Show file tree
Hide file tree
Showing 3 changed files with 382 additions and 5 deletions.
307 changes: 307 additions & 0 deletions BTCPayServer/Blazor/Keypad.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,307 @@
@using Newtonsoft.Json.Linq
<form class="d-flex flex-column gap-4" @onsubmit="Submit">
<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" style="font-size:@(FontSize + "px")" id="Amount">@FormatCurrency(GetTotal(), false)</div>
<div class="text-muted text-center mt-2" id="Calculation">@Calculation(Model)</div>
</div>
@if (IsDiscountEnabled || IsTipEnabled)
{
<div id="ModeTabs" class="tab-content mb-n2">
@if (IsDiscountEnabled)
{
<div class="tab-pane fade px-2 @(Mode == InputMode.Discount ? "show active" : "")" role="tabpanel" aria-labelledby="ModeTablist-Discount">
<div class="h4 fw-semibold text-muted text-center" id="Discount">
<span class="h3 text-body me-1">@Model.DiscountPercent%</span> discount
</div>
</div>
}
@if (IsTipEnabled)
{
var tip = GetTip();
<div class="tab-pane fade px-2 @(Mode == InputMode.Tip ? "show active" : "")" role="tabpanel" aria-labelledby="ModeTablist-Tip">
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-2">
@if (CustomTipPercentages != null)
{
<button
id="Tip-Custom"
type="button"
class="btcpay-pill @(Model.TipPercent == null ? "active" : "")"
@onclick="() => Model.TipPercent = null">
@(Model.Tip is > 0 ? FormatCurrency(Model.Tip.Value, true) : "Custom")
</button>
@foreach(var percentage in CustomTipPercentages)
{
<button
type="button"
id="Tip-@percentage"
class="btcpay-pill @(Model.TipPercent == percentage ? "active" : "")"
@onclick="() => Model.TipPercent = percentage">
@percentage%
</button>
}
}
else
{
<div class="h5 fw-semibold text-muted text-center">
Amount@(tip > 0 ? $": {FormatCurrency(tip, true)}" : "")
</div>
}
</div>
</div>
}
</div>
<div id="ModeTablist" class="nav btcpay-pills align-items-center justify-content-center mb-n2 pb-1" role="tablist">
@foreach (var mode in GetModes())
{
<input id="ModeTablist-@mode" name="mode" value="@mode" type="radio" role="tab"
aria-controls="Mode-@mode" aria-selected="@(Mode == mode ? "true" : "false")"
data-bs-toggle="pill" data-bs-target="#Mode-@mode"
checked="@(Mode == mode)"
disabled="@(Mode != InputMode.Amount && GetAmount() is 0)"
@onclick="() => Mode = mode">
<label for="ModeTablist-@mode">@mode</label>
}
</div>
}
<div class="keypad">
@foreach (var key in Keys)
{
<button disabled="@(key == '+' && Mode != InputMode.Amount)" @onclick="@(e => KeyPress(key))" @onclick:preventDefault @ondblclick="@(e => DoublePress(key))" type="button" class="btn btn-secondary btn-lg" data-key="@key">@key</button>
}
</div>
<button class="btn btn-lg btn-primary mx-3" type="submit" disabled="@IsSubmitting" id="pay-button">
@if (IsSubmitting)
{
<div class="spinner-border spinner-border-sm" role="status">
<span class="sr-only">Loading...</span>
</div>
}
else
{
<span>Charge</span>
}
</button>
</form>

@code {
[Parameter]
public string CurrencyCode { get; set; }
[Parameter]
public string CurrencySymbol { get; set; }
[Parameter]
public int CurrencyDivisibility { get; set; }
[Parameter]
public bool IsDiscountEnabled { get; set; }
[Parameter]
public bool IsTipEnabled { get; set; }
[Parameter]
public int[] CustomTipPercentages { get; set; }

private bool IsSubmitting { get; set; }

const int DefaultFontSize = 64;
static char[] Keys = { '1', '2', '3', '4', '5', '6', '7', '8', '9', 'C', '0', '+' };
public int FontSize { get; set; } = DefaultFontSize;

public enum InputMode
{
Amount,
Discount,
Tip
}

public InputMode Mode { get; set; } = InputMode.Amount;

public class KeypadModel
{
public List<decimal> Amounts { get; set; } = new ();
public int? DiscountPercent { get; set; }
public int? TipPercent { get; set; }
public decimal? Tip { get; set; }
}

private KeypadModel Model { get; set; }

protected override void OnInitialized()
{
Model ??= new KeypadModel();
}

private void Submit()
{
IsSubmitting = true;
}

public void KeyPress(char key = '1')
{
if (Mode == InputMode.Amount) {
var lastIndex = Model.Amounts.Count - 1;
var lastAmount = Model.Amounts[lastIndex];
if (key == 'C') {
if (lastAmount == 0 && lastIndex == 0) {
// clear completely
Clear();
} else if (lastAmount == 0) {
// remove latest value
Model.Amounts.RemoveAt(lastIndex);
} else {
// clear latest value
Model.Amounts[lastIndex] = 0;
}
} else if (key == '+' && lastAmount != 0) {
Model.Amounts.Add(0);
} else { // Is a digit
Model.Amounts[lastIndex] = ApplyKeyToValue(key, lastAmount, CurrencyDivisibility);
}
} else {
if (key == 'C') {
if (Mode == InputMode.Tip)
{
Model.Tip = null;
Model.TipPercent = null;
}
else
{
Model.DiscountPercent = null;
}
} else {
var divisibility = Mode == InputMode.Tip ? CurrencyDivisibility : 0;
if (Mode == InputMode.Tip)
{
Model.Tip = ApplyKeyToValue(key, Model.Tip ?? 0, divisibility);
Model.TipPercent = null;
}
else
{
Model.DiscountPercent = (int)ApplyKeyToValue(key, Model.DiscountPercent ?? 0, divisibility);
}
}
}
}

public decimal ApplyKeyToValue(char key, decimal value, int divisibility)
{
var val = value is 0 ? "" : Formatted(value, divisibility);
val = (val + key)
.Replace(".", "")
.PadLeft(divisibility, '0');
//.Replace(new Regex("(\\d*)(\\d{{divisibility}})", "\\1.\\2");
return decimal.Parse(val);
}

public void DoublePress(char key)
{
if (key == 'C') {
Clear();
}
}

public void Clear()
{
Model.Amounts.Clear();
Model.DiscountPercent = null;
Model.TipPercent = null;
Model.Tip = null;
Mode = InputMode.Amount;
}

private JObject GetData()
{
var data = new JObject
{
["subTotal"] = GetAmount(),
["total"] = GetTotal(),
};

var discount = GetDiscount();
if (discount > 0)
{
data["discount"] = discount;
}
if (Model.DiscountPercent is > 0)
{
data["discountPercentage"] = Model.DiscountPercent;
}

var tip = GetTip();
if (tip > 0)
{
data["tip"] = tip;
}
if (Model.TipPercent != null)
{
data["tipPercentage"] = Model.TipPercent;
}
return data;
}

private List<InputMode> GetModes()
{
var modes = new List<InputMode> { InputMode.Amount };
if (IsDiscountEnabled) modes.Add(InputMode.Discount);
if (IsTipEnabled) modes.Add(InputMode.Tip);
return modes;
}

private decimal GetAmount()
{
return Model.Amounts.Count > 0 ? Model.Amounts.Sum() : Model.Amounts.FirstOrDefault();
}

private decimal GetDiscount()
{
var amount = GetAmount();
return amount > 0 && Model.DiscountPercent is > 0
? Math.Round(amount * (Model.DiscountPercent.Value / 100.0m), CurrencyDivisibility)
: 0;
}

private decimal GetTip()
{
if (Model.TipPercent is > 0) {
var amount = GetAmount() - GetDiscount();
return Math.Round(amount * (Model.TipPercent.Value / 100.0m), CurrencyDivisibility);
}
return Model.Tip is > 0 ? Math.Round(Model.Tip.Value, CurrencyDivisibility) : 0.0m;
}

private decimal GetTotal()
{
return GetAmount() - GetDiscount() + GetTip();
}

private string Calculation(KeypadModel model)
{
if (model.Amounts.Count < 2 && model.DiscountPercent is not > 0 && !model.Tip.HasValue) return null;
var calc = string.Join(" + ", model.Amounts.Select(amt => FormatCurrency(amt, true)));
var discount = GetDiscount();
if (discount > 0) calc += $" - {FormatCurrency(discount, true)} (${model.DiscountPercent}%)";
var tip = GetTip();
if (model.Tip > 0) calc += $" + ${FormatCurrency(tip, true)}";
if (model.TipPercent > 0) calc += $" (${model.TipPercent}%)";
return calc;
}

private string FormatCurrency(decimal value, bool withSymbol)
{
if (CurrencyCode is "BTC" or "SATS") return FormatCrypto(value, withSymbol);
try {
var symbol = withSymbol ? $" {CurrencySymbol ?? CurrencyCode}" : "";
return $"{Formatted(value, CurrencyDivisibility)}{symbol}";
}
catch (Exception)
{
return FormatCrypto(value, withSymbol);
}
}

private string FormatCrypto(decimal value, bool withSymbol) {
var symbol = withSymbol ? $" {CurrencySymbol ?? CurrencyCode}" : "";
return $"{Formatted(value, CurrencyDivisibility)}{symbol}";
}

private string Formatted(decimal value, int divisibility) {
return string.Format($"{{0:0.{new string('0', divisibility)}}}", value);
}
}
65 changes: 65 additions & 0 deletions BTCPayServer/Blazor/Keypad.razor.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@

/* modes */
#ModeTabs {
min-height: 2.75rem;
}

/* keypad */
.keypad {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.keypad .btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
position: relative;
border-radius: 0;
font-weight: var(--btcpay-font-weight-semibold);
font-size: 24px;
min-height: 3.5rem;
height: 8vh;
max-height: 6rem;
color: var(--btcpay-body-text);
}
.keypad .btn[data-key="+"] {
font-size: 2.25em;
}
.btcpay-pills label,
.btn-secondary.rounded-pill {
padding-left: 1rem;
padding-right: 1rem;
}

/* make borders collapse by shifting rows and columns by 1px */
/* second column */
.keypad .btn:nth-child(3n-1) {
margin-left: -1px;
}
/* third column */
.keypad .btn:nth-child(3n) {
margin-left: -1px;
}
/* from second row downwards */
.keypad .btn:nth-child(n+4) {
margin-top: -1px;
}
/* ensure highlighted button is topmost */
.keypad .btn:hover,
.keypad .btn:focus,
.keypad .btn:active {
z-index: 1;
}

#Calculation {
min-height: 1.5rem;
}

/* fix sticky hover effect on mobile browsers */
@media (hover: none) {
.keypad .btn-secondary:hover,
.actions .btn-secondary:hover {
border-color: var(--btcpay-secondary-border-active) !important;
}
}

0 comments on commit ae0567a

Please sign in to comment.