From 1955fec8539d56905463ea635fb91b7277c70240 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 11 Apr 2026 10:59:45 -0700 Subject: [PATCH 1/2] RE1-T112 Fixes --- Core/Resgrid.Config/InfoConfig.cs | 2 +- .../User/Views/Shared/_MinimalLayout.cshtml | 54 +++++++ .../User/Views/Subscription/Index.cshtml | 128 +++++++-------- .../SelectRegistrationPlan.cshtml | 146 ++++++++---------- 4 files changed, 189 insertions(+), 141 deletions(-) create mode 100644 Web/Resgrid.Web/Areas/User/Views/Shared/_MinimalLayout.cshtml diff --git a/Core/Resgrid.Config/InfoConfig.cs b/Core/Resgrid.Config/InfoConfig.cs index 4fda92af..95ec0c07 100644 --- a/Core/Resgrid.Config/InfoConfig.cs +++ b/Core/Resgrid.Config/InfoConfig.cs @@ -44,7 +44,7 @@ public static class InfoConfig LocationInfo = "This is the Resgrid system hosted in Central Europe (on OVH). This system services Resgrid customers in the European Union to help with data (GDPR) compliance requirements.", IsDefault = false, - AppUrl = "https://app.eu-central.resgrid.com", + AppUrl = "https://app-eu-central.resgrid.com", ApiUrl = "https://api-eu-central.resgrid.com", AllowsFreeAccounts = false } diff --git a/Web/Resgrid.Web/Areas/User/Views/Shared/_MinimalLayout.cshtml b/Web/Resgrid.Web/Areas/User/Views/Shared/_MinimalLayout.cshtml new file mode 100644 index 00000000..d5bb1fd4 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Shared/_MinimalLayout.cshtml @@ -0,0 +1,54 @@ +@using Microsoft.AspNetCore.Http + + + + + + @ViewData["Title"] + + + + + + + + + @if (IsSectionDefined("Styles")) + { + @RenderSection("Styles", required: false) + } + + +
+ @RenderBody() +
+ + + + + + + + @if (IsSectionDefined("Scripts")) + { + @RenderSection("Scripts", required: false) + } + + diff --git a/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml index 2fa1b859..0e4d087c 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml @@ -5,6 +5,11 @@ @inject IStringLocalizer localizer @{ ViewBag.Title = "Resgrid | " + @localizer["SubscriptionHeader"]; + + var locationName = SystemBehaviorConfig.LocationName ?? "US-West"; + var location = InfoConfig.Locations.Find(l => l.Name == locationName) ?? InfoConfig.Locations[0]; + var isEU = locationName.StartsWith("EU", StringComparison.OrdinalIgnoreCase); + var currencySymbol = isEU ? "\u20AC" : "$"; } @section Styles @@ -319,7 +324,17 @@ @if (Model.Plan == null || Model.Plan.PlanId == 1) { -

Move the blue slider below with the arrows to select the number of Entities (Users + Units) you require. You can also type in the Entity count (in increments of 10) in the text box below. Please the "Buy Yearly" or "Buy Monthly" depending on the payment interval you wish. This will then take you to the Stripe checkout page. Please note you cannot buy a 10 entity pack as that is our free plan.

+

Select the number of Entities (Users + Units) you require using the slider or text box below. + @if (!isEU) { Your first 10 entities are included at no charge — each } else { Each } + additional pack of 10 entities is billed at the rate shown. Select "Buy Yearly" or "Buy Monthly" to proceed to checkout.

+ + @if (isEU) + { +
+ European Region — Pricing includes GDPR-compliant data hosting. All prices shown in EUR (@currencySymbol) with a regional adjustment. No free tier is available. +
+ } +
@@ -345,21 +360,19 @@
- + Monthly billing amount
-

- .00 +

0.00

- + Yearly (annual) billing amount
-

- .00 +

0.00


@@ -627,6 +640,9 @@ + var IS_EU = @(isEU ? "true" : "false"); + var EU_MULTIPLIER = 1.25; + $(document).ready(function () { $("#slider").slider({ animate: true, @@ -639,7 +655,7 @@ handle.text($(this).slider("value")); }, slide: function (event, ui) { - update(1, ui.value); //changed + update(1, ui.value); } }); @@ -663,97 +679,87 @@ } }); - //Added, set initial value. $("#amount").val(10); - //$("#duration").val(0); - //$("#amount-label").text(0); - $("#amount-input").val(0); - $("#monthly-label").text(0); - $("#yearly-label").text(0); - - //$("#duration-label").text(0); + $("#amount-input").val(10); + $("#monthly-label").text('0.00'); + $("#yearly-label").text('0.00'); update(); }); - //changed. now with parameter - function update(slider, val) { + function update(sliderFlag, sliderVal) { let handle = $("#handle-text"); - - //changed. Now, directly take value from ui.value. if not set (initial, will use current value.) - var $amount = slider == 1 ? val : $("#amount").val(); - var $duration = slider == 2 ? val : $("#duration").val(); - - /* commented - $amount = $( "#slider" ).slider( "value" ); - $duration = $( "#slider2" ).slider( "value" ); - */ + var $amount = sliderFlag == 1 ? sliderVal : $("#amount").val(); + var showButtons = IS_EU ? ($amount >= 10) : ($amount > 10); handle.text($amount); - //$total = "$" + ($amount * $duration); $("#amount").val($amount); - //$("#amount-label").text($amount); $("#amount-input").val($amount); - if ($amount > 10) { - const totalCostMonthly = calculateCostFromUsers($amount, true); - const totalCostYearly = calculateCostFromUsers($amount, false); + if (showButtons) { + var totalCostMonthly = calculateCostFromUsers($amount, true); + var totalCostYearly = calculateCostFromUsers($amount, false); - $("#monthly-label").text(totalCostMonthly); - $("#yearly-label").text(totalCostYearly); + $("#monthly-label").text(totalCostMonthly.toFixed(2)); + $("#yearly-label").text(totalCostYearly.toFixed(2)); $("#buyYearlyButton").show(); $("#buyMonthlyButton").show(); } else { - $("#monthly-label").text(0); - $("#yearly-label").text(0); + $("#monthly-label").text('0.00'); + $("#yearly-label").text('0.00'); $("#buyYearlyButton").hide(); $("#buyMonthlyButton").hide(); } } - const calculateCostFromUsers = (totalNumUsers, isMonthly) => { - let marginalBreakdownStrs = []; - const pricingTiersMonthly = [ - //{ tier: 0, marginalUserSlots: 1, costPerUser: 0.0 }, - { tier: 0, marginalUserSlots: 5, costPerUser: 20.0 }, - { tier: 1, marginalUserSlots: 100, costPerUser: 2.0 }, - { tier: 2, marginalUserSlots: 1000, costPerUser: 1.5 }, - { tier: 3, marginalUserSlots: 5000, costPerUser: 1.0 }, - { tier: 4, marginalUserSlots: 999999999, costPerUser: 0.5 }, + { marginalUserSlots: 1, costPerUser: 0.0 }, + { marginalUserSlots: 5, costPerUser: 20.0 }, + { marginalUserSlots: 100, costPerUser: 2.0 }, + { marginalUserSlots: 1000, costPerUser: 1.5 }, + { marginalUserSlots: 5000, costPerUser: 1.0 }, + { marginalUserSlots: 999999999, costPerUser: 0.5 }, ]; const pricingTiersYearly = [ - //{ tier: 0, marginalUserSlots: 1, costPerUser: 0.0 }, - { tier: 0, marginalUserSlots: 5, costPerUser: 200.0 }, - { tier: 1, marginalUserSlots: 100, costPerUser: 20.0 }, - { tier: 2, marginalUserSlots: 1000, costPerUser: 15.0 }, - { tier: 3, marginalUserSlots: 5000, costPerUser: 10.0 }, - { tier: 4, marginalUserSlots: 999999999, costPerUser: 5.0 }, + { marginalUserSlots: 1, costPerUser: 0.0 }, + { marginalUserSlots: 5, costPerUser: 200.0 }, + { marginalUserSlots: 100, costPerUser: 20.0 }, + { marginalUserSlots: 1000, costPerUser: 15.0 }, + { marginalUserSlots: 5000, costPerUser: 10.0 }, + { marginalUserSlots: 999999999, costPerUser: 5.0 }, ]; let finalCost = 0.0; - let remainingUsers = (totalNumUsers / 10) - 1; // First 10 users are free. - let pricingTiers = isMonthly ? pricingTiersMonthly : pricingTiersYearly; + let remainingUsers = totalNumUsers / 10; + const baseTiers = isMonthly ? pricingTiersMonthly : pricingTiersYearly; + + let tiers; + if (IS_EU) { + tiers = [{ + marginalUserSlots: baseTiers[0].marginalUserSlots + baseTiers[1].marginalUserSlots, + costPerUser: baseTiers[1].costPerUser + }].concat(baseTiers.slice(2)); + } else { + tiers = baseTiers; + } - for (let i = 0; i < pricingTiers.length; i++) { - let tier = pricingTiers[i]; + for (let i = 0; i < tiers.length; i++) { + let tier = tiers[i]; if (tier.marginalUserSlots < remainingUsers) { - // calculate cost finalCost += tier.marginalUserSlots * tier.costPerUser; - marginalBreakdownStrs.push(`${tier.marginalUserSlots} @@ $${tier.costPerUser}`); - // remove the users remainingUsers -= tier.marginalUserSlots; } else { - // only need the partial group of the marginal user slots/ potentially all of them finalCost += tier.costPerUser * remainingUsers; - marginalBreakdownStrs.push(`${remainingUsers} @@ $${tier.costPerUser}`); remainingUsers = 0; } - //console.log("RemainingUsers: ", remainingUsers); + } + + if (IS_EU) { + finalCost = Math.round(finalCost * EU_MULTIPLIER); } return finalCost; diff --git a/Web/Resgrid.Web/Areas/User/Views/Subscription/SelectRegistrationPlan.cshtml b/Web/Resgrid.Web/Areas/User/Views/Subscription/SelectRegistrationPlan.cshtml index a4c6ae52..f34197cf 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Subscription/SelectRegistrationPlan.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Subscription/SelectRegistrationPlan.cshtml @@ -2,6 +2,13 @@ @model Resgrid.Web.Areas.User.Models.Subscription.SelectRegistrationPlanView @{ ViewBag.Title = "Resgrid | Select Your Plan"; + Layout = "~/Areas/User/Views/Shared/_MinimalLayout.cshtml"; + + var locationName = SystemBehaviorConfig.LocationName ?? "US-West"; + var location = InfoConfig.Locations.Find(l => l.Name == locationName) ?? InfoConfig.Locations[0]; + var isEU = locationName.StartsWith("EU", StringComparison.OrdinalIgnoreCase); + var currencySymbol = isEU ? "\u20AC" : "$"; + var minEntities = isEU ? 10 : 20; } @section Styles @@ -16,49 +23,14 @@ border: 1px solid #e7eaec; } - .price-form { - width: 100%; - padding-top: 10px; - } - - .price-slider { - width: 100%; - padding-bottom: 20px; - } - - .entity-range { - width: 100%; - margin: 15px 0; - } - - .price-display { - font-weight: 600; - font-size: 28px; - display: inline-block; - } - - .price-label { - font-weight: 200; - font-size: 18px; - } - - .price-cents { - font-weight: 200; - color: #7f8c8d; - font-size: 14px; - } + .price-form { width: 100%; padding-top: 10px; } + .price-slider { width: 100%; padding-bottom: 20px; } + .entity-range { width: 100%; margin: 15px 0; } + .price-display { font-weight: 600; font-size: 28px; display: inline-block; } + .price-label { font-weight: 200; font-size: 18px; } - hr.style { - border: 0; - border-bottom: 1px dashed #ccc; - background: #999; - margin-top: 5px; - } - - input[type="number"] { - width: 80px !important; - font-size: 18px; - } + hr.style { border: 0; border-bottom: 1px dashed #ccc; background: #999; margin-top: 5px; } + input[type="number"] { width: 80px !important; font-size: 18px; } h4.great { background: #00ac98; @@ -78,7 +50,7 @@

Select Your Plan

@@ -95,25 +67,34 @@ Free tier signups are not available on this Resgrid instance. Please select a plan below to complete your registration. -

Select the number of Entities (Users + Units) you require using the slider or text box below. Your first 10 entities are included at no charge — each additional pack of 10 entities is billed at the rate shown. Select Buy Yearly or Buy Monthly to proceed to checkout.

+ @if (isEU) + { +
+ European Region — Pricing includes GDPR-compliant data hosting. All prices shown in EUR (@currencySymbol) with a regional adjustment. No free tier is available. +
+ } + +

Select the number of Entities (Users + Units) you require using the slider or text box below. + @if (!isEU) { Your first 10 entities are included at no charge — each } else { Each } + additional pack of 10 entities is billed at the rate shown. Select Buy Yearly or Buy Monthly to proceed to checkout.

Users or Units sold in packs of 10
- +
- Entities + Entities
- Monthly ($): + Monthly (@currencySymbol):
0.00 @@ -121,7 +102,7 @@
- Yearly ($): + Yearly (@currencySymbol):
0.00 @@ -130,18 +111,10 @@
- @if (Model.IsPaddleDepartment) { - - } else { - - } +
- @if (Model.IsPaddleDepartment) { - - } else { - - } +
@@ -181,12 +154,15 @@ var stripe = Stripe('@Model.StripeKey'); } var discountCode = '@Html.Raw(Model.DiscountCode ?? "")'; + var IS_EU = @(isEU ? "true" : "false"); + var EU_MULTIPLIER = 1.25; function stripeCheckout(id) { var amount = parseInt(document.getElementById('amount-input').value) || 0; + var minAmount = @minEntities; - if (amount > 10) { - var packs = (amount / 10) - 1; + if (amount >= minAmount) { + var packs = IS_EU ? (amount / 10) : (amount / 10) - 1; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Subscription/GetStripeSession?id=' + id + '&count=' + packs + (discountCode ? '&discountCode=' + encodeURIComponent(discountCode) : ''), @@ -206,15 +182,16 @@ swal({ title: "Connection Error", text: "Unable to reach the server. Please check your connection and try again.", icon: "error", buttons: true, dangerMode: false }); }); } else { - swal({ title: "Cannot Purchase", text: "Please select more than 10 entities to purchase a plan.", icon: "warning", buttons: true, dangerMode: false }); + swal({ title: "Cannot Purchase", text: "Please select more entities to purchase a plan.", icon: "warning", buttons: true, dangerMode: false }); } } function paddleCheckout(id) { var amount = parseInt(document.getElementById('amount-input').value) || 0; + var minAmount = @minEntities; - if (amount > 10) { - var packs = (amount / 10) - 1; + if (amount >= minAmount) { + var packs = IS_EU ? (amount / 10) : (amount / 10) - 1; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Subscription/GetPaddleCheckout?id=' + id + '&count=' + packs + (discountCode ? '&discountCode=' + encodeURIComponent(discountCode) : ''), @@ -223,24 +200,20 @@ }).done(function (data) { if (data) { if (data.HasActiveSub) { - swal({ title: "Active Subscription", text: "You already have an active subscription. Please manage your existing subscription instead.", icon: "warning", buttons: true, dangerMode: false }); + swal({ title: "Active Subscription", text: "You already have an active subscription.", icon: "warning", buttons: true, dangerMode: false }); return; } - if (!data.PriceId) { swal({ title: "Checkout Error", text: "Unable to create a checkout session. Please try again.", icon: "error", buttons: true, dangerMode: false }); return; } - var checkoutSettings = { settings: { successUrl: resgrid.absoluteBaseUrl + '/User/Subscription/PaddleProcessing?planId=' + id }, items: [{ priceId: data.PriceId, quantity: packs }] }; - if (data.CustomerId) { checkoutSettings.customer = { id: data.CustomerId }; } - Paddle.Checkout.open(checkoutSettings); } else { swal({ title: "Checkout Error", text: "Unable to create a checkout session. Please try again.", icon: "error", buttons: true, dangerMode: false }); @@ -249,12 +222,13 @@ swal({ title: "Connection Error", text: "Unable to reach the server. Please check your connection and try again.", icon: "error", buttons: true, dangerMode: false }); }); } else { - swal({ title: "Cannot Purchase", text: "Please select more than 10 entities to purchase a plan.", icon: "warning", buttons: true, dangerMode: false }); + swal({ title: "Cannot Purchase", text: "Please select more entities to purchase a plan.", icon: "warning", buttons: true, dangerMode: false }); } } var calculateCostFromUsers = function (totalNumUsers, isMonthly) { var pricingTiersMonthly = [ + { marginalUserSlots: 1, costPerUser: 0.0 }, { marginalUserSlots: 5, costPerUser: 20.0 }, { marginalUserSlots: 100, costPerUser: 2.0 }, { marginalUserSlots: 1000, costPerUser: 1.5 }, @@ -263,6 +237,7 @@ ]; var pricingTiersYearly = [ + { marginalUserSlots: 1, costPerUser: 0.0 }, { marginalUserSlots: 5, costPerUser: 200.0 }, { marginalUserSlots: 100, costPerUser: 20.0 }, { marginalUserSlots: 1000, costPerUser: 15.0 }, @@ -271,11 +246,21 @@ ]; var finalCost = 0.0; - var remainingUsers = (totalNumUsers / 10) - 1; - var pricingTiers = isMonthly ? pricingTiersMonthly : pricingTiersYearly; + var remainingUsers = totalNumUsers / 10; + var baseTiers = isMonthly ? pricingTiersMonthly : pricingTiersYearly; + var tiers; + + if (IS_EU) { + tiers = [{ + marginalUserSlots: baseTiers[0].marginalUserSlots + baseTiers[1].marginalUserSlots, + costPerUser: baseTiers[1].costPerUser + }].concat(baseTiers.slice(2)); + } else { + tiers = baseTiers; + } - for (var i = 0; i < pricingTiers.length; i++) { - var tier = pricingTiers[i]; + for (var i = 0; i < tiers.length; i++) { + var tier = tiers[i]; if (tier.marginalUserSlots < remainingUsers) { finalCost += tier.marginalUserSlots * tier.costPerUser; remainingUsers -= tier.marginalUserSlots; @@ -285,13 +270,18 @@ } } + if (IS_EU) { + finalCost = Math.round(finalCost * EU_MULTIPLIER); + } + return finalCost; }; function updatePricing() { var amount = parseInt(document.getElementById('amount-input').value) || 0; + var showButtons = IS_EU ? (amount >= 10) : (amount > 10); - if (amount > 10) { + if (showButtons) { document.getElementById('monthly-label').textContent = calculateCostFromUsers(amount, true).toFixed(2); document.getElementById('yearly-label').textContent = calculateCostFromUsers(amount, false).toFixed(2); document.getElementById('buyYearlyButton').style.display = ''; @@ -309,7 +299,6 @@ var input = document.getElementById('amount-input'); var isPaddle = @(Model.IsPaddleDepartment ? "true" : "false"); - // Wire up buy buttons document.getElementById('buyYearlyButton').addEventListener('click', function (e) { e.preventDefault(); isPaddle ? paddleCheckout(36) : stripeCheckout(36); @@ -319,16 +308,15 @@ isPaddle ? paddleCheckout(37) : stripeCheckout(37); }); - // Sync slider → input slider.addEventListener('input', function () { input.value = slider.value; updatePricing(); }); - // Sync input → slider input.addEventListener('change', function () { - var val = parseInt(input.value) || 20; - if (val < 20) val = 20; + var minVal = @minEntities; + var val = parseInt(input.value) || minVal; + if (val < minVal) val = minVal; if (val > 2000) val = 2000; val = Math.ceil(val / 10) * 10; input.value = val; From 7f99e3d21efa2858c629017979484316bca845c0 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Sat, 11 Apr 2026 11:42:34 -0700 Subject: [PATCH 2/2] RE1-T112 PR#327 fixes --- Core/Resgrid.Model/DepartmentSettingTypes.cs | 1 + Core/Resgrid.Services/WeatherAlertService.cs | 77 ++++++++++++++--- .../Controllers/v4/WeatherAlertsController.cs | 19 +++- .../SaveWeatherAlertSettingsInput.cs | 3 + .../WeatherAlerts/WeatherAlertSettingsData.cs | 11 +++ .../User/Views/Subscription/Index.cshtml | 86 ++++++++----------- .../SelectRegistrationPlan.cshtml | 4 +- .../User/Views/WeatherAlerts/Settings.cshtml | 49 +++++++++-- 8 files changed, 176 insertions(+), 74 deletions(-) diff --git a/Core/Resgrid.Model/DepartmentSettingTypes.cs b/Core/Resgrid.Model/DepartmentSettingTypes.cs index 7a335290..925aa6c5 100644 --- a/Core/Resgrid.Model/DepartmentSettingTypes.cs +++ b/Core/Resgrid.Model/DepartmentSettingTypes.cs @@ -45,5 +45,6 @@ public enum DepartmentSettingTypes WeatherAlertAutoMessageSeverity = 41, WeatherAlertCallIntegration = 42, WeatherAlertCacheMinutes = 43, + WeatherAlertAutoMessageSchedule = 44, } } diff --git a/Core/Resgrid.Services/WeatherAlertService.cs b/Core/Resgrid.Services/WeatherAlertService.cs index f226fef3..7ad101c4 100644 --- a/Core/Resgrid.Services/WeatherAlertService.cs +++ b/Core/Resgrid.Services/WeatherAlertService.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Newtonsoft.Json; using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Helpers; @@ -319,15 +320,27 @@ public async Task SendPendingNotificationsAsync(CancellationToken ct = default) continue; } - // Get the auto-message severity threshold setting - var thresholdSetting = await _departmentSettingsRepository.GetDepartmentSettingByIdTypeAsync( - departmentId, DepartmentSettingTypes.WeatherAlertAutoMessageSeverity); + // Load per-severity schedule (if configured) + List schedule = null; + var scheduleSetting = await _departmentSettingsRepository.GetDepartmentSettingByIdTypeAsync( + departmentId, DepartmentSettingTypes.WeatherAlertAutoMessageSchedule); + if (scheduleSetting != null && !string.IsNullOrWhiteSpace(scheduleSetting.Setting)) + { + try { schedule = JsonConvert.DeserializeObject>(scheduleSetting.Setting); } + catch { } + } - int threshold = (int)WeatherAlertSeverity.Severe; // Default: Severe=1 - if (thresholdSetting != null && int.TryParse(thresholdSetting.Setting, out var parsed)) - threshold = parsed; + // Fall back to legacy threshold if no schedule configured + int legacyThreshold = (int)WeatherAlertSeverity.Severe; + if (schedule == null || schedule.Count == 0) + { + var thresholdSetting = await _departmentSettingsRepository.GetDepartmentSettingByIdTypeAsync( + departmentId, DepartmentSettingTypes.WeatherAlertAutoMessageSeverity); + if (thresholdSetting != null && int.TryParse(thresholdSetting.Setting, out var parsed)) + legacyThreshold = parsed; + } - // Load department for sender info + // Load department for sender info and time conversion Department department = null; try { @@ -340,9 +353,8 @@ public async Task SendPendingNotificationsAsync(CancellationToken ct = default) foreach (var alert in group) { - // Only send notifications for alerts meeting severity threshold - // Lower enum value = higher severity (Extreme=0, Severe=1, etc.) - if (alert.Severity <= threshold) + bool shouldSend = ShouldSendAutoMessage(alert.Severity, schedule, legacyThreshold, department); + if (shouldSend) { try { @@ -594,5 +606,50 @@ private static string Truncate(string value, int maxLength) return value.Substring(0, maxLength); } + + private static bool ShouldSendAutoMessage(int severity, List schedule, int legacyThreshold, Department department) + { + if (schedule != null && schedule.Count > 0) + { + var entry = schedule.FirstOrDefault(s => s.Severity == severity); + + // Severity not in schedule — don't send + if (entry == null || !entry.Enabled) + return false; + + // Check time window (StartHour == 0 && EndHour == 0 means 24h/always) + if (entry.StartHour == 0 && entry.EndHour == 0) + return true; + + // Get department local time + var now = DateTime.UtcNow; + if (department != null) + now = now.TimeConverter(department); + + int currentHour = now.Hour; + + if (entry.StartHour <= entry.EndHour) + { + // Same-day window: e.g. 6-18 + return currentHour >= entry.StartHour && currentHour < entry.EndHour; + } + else + { + // Overnight window: e.g. 18-6 (6pm to 6am) + return currentHour >= entry.StartHour || currentHour < entry.EndHour; + } + } + + // Legacy: simple severity threshold + return severity <= legacyThreshold; + } + + private class AutoMessageSeveritySchedule + { + public int Severity { get; set; } + public bool Enabled { get; set; } + public int StartHour { get; set; } + public int EndHour { get; set; } + } } } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs index e4bde09b..2921e89a 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/WeatherAlertsController.cs @@ -357,13 +357,20 @@ public async Task> SaveSettings([Fro await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, input.AutoMessageSeverity.ToString(), DepartmentSettingTypes.WeatherAlertAutoMessageSeverity); await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, input.CallIntegrationEnabled.ToString(), DepartmentSettingTypes.WeatherAlertCallIntegration); + if (input.AutoMessageSchedule != null) + { + var scheduleJson = Newtonsoft.Json.JsonConvert.SerializeObject(input.AutoMessageSchedule); + await _departmentSettingsService.SaveOrUpdateSettingAsync(DepartmentId, scheduleJson, DepartmentSettingTypes.WeatherAlertAutoMessageSchedule); + } + var result = new GetWeatherAlertSettingsResult(); result.Data = new WeatherAlertSettingsData { WeatherAlertsEnabled = input.WeatherAlertsEnabled, MinimumSeverity = input.MinimumSeverity, AutoMessageSeverity = input.AutoMessageSeverity, - CallIntegrationEnabled = input.CallIntegrationEnabled + CallIntegrationEnabled = input.CallIntegrationEnabled, + AutoMessageSchedule = input.AutoMessageSchedule }; ResponseHelper.PopulateV4ResponseData(result); @@ -378,6 +385,7 @@ private async Task GetWeatherAlertSettingsDataAsync() var minSeveritySetting = await _departmentSettingsService.GetSettingByTypeAsync(DepartmentId, DepartmentSettingTypes.WeatherAlertMinimumSeverity); var autoMsgSetting = await _departmentSettingsService.GetSettingByTypeAsync(DepartmentId, DepartmentSettingTypes.WeatherAlertAutoMessageSeverity); var callIntSetting = await _departmentSettingsService.GetSettingByTypeAsync(DepartmentId, DepartmentSettingTypes.WeatherAlertCallIntegration); + var scheduleSetting = await _departmentSettingsService.GetSettingByTypeAsync(DepartmentId, DepartmentSettingTypes.WeatherAlertAutoMessageSchedule); if (enabledSetting != null && !string.IsNullOrWhiteSpace(enabledSetting.Setting)) settings.WeatherAlertsEnabled = bool.TryParse(enabledSetting.Setting, out var enabled) && enabled; @@ -391,6 +399,15 @@ private async Task GetWeatherAlertSettingsDataAsync() if (callIntSetting != null && !string.IsNullOrWhiteSpace(callIntSetting.Setting)) settings.CallIntegrationEnabled = bool.TryParse(callIntSetting.Setting, out var callInt) && callInt; + if (scheduleSetting != null && !string.IsNullOrWhiteSpace(scheduleSetting.Setting)) + { + try + { + settings.AutoMessageSchedule = Newtonsoft.Json.JsonConvert.DeserializeObject>(scheduleSetting.Setting); + } + catch { } + } + return settings; } diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/SaveWeatherAlertSettingsInput.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/SaveWeatherAlertSettingsInput.cs index d4006623..fba8b462 100644 --- a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/SaveWeatherAlertSettingsInput.cs +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/SaveWeatherAlertSettingsInput.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace Resgrid.Web.Services.Models.v4.WeatherAlerts { public class SaveWeatherAlertSettingsInput @@ -6,5 +8,6 @@ public class SaveWeatherAlertSettingsInput public int MinimumSeverity { get; set; } public int AutoMessageSeverity { get; set; } public bool CallIntegrationEnabled { get; set; } + public List AutoMessageSchedule { get; set; } } } diff --git a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSettingsData.cs b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSettingsData.cs index 7767edb6..4975d8ff 100644 --- a/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSettingsData.cs +++ b/Web/Resgrid.Web.Services/Models/v4/WeatherAlerts/WeatherAlertSettingsData.cs @@ -1,3 +1,5 @@ +using System.Collections.Generic; + namespace Resgrid.Web.Services.Models.v4.WeatherAlerts { public class WeatherAlertSettingsData @@ -6,5 +8,14 @@ public class WeatherAlertSettingsData public int MinimumSeverity { get; set; } public int AutoMessageSeverity { get; set; } public bool CallIntegrationEnabled { get; set; } + public List AutoMessageSchedule { get; set; } + } + + public class WeatherAlertSeverityScheduleData + { + public int Severity { get; set; } + public bool Enabled { get; set; } + public int StartHour { get; set; } + public int EndHour { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml index 0e4d087c..b9d06884 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml @@ -265,13 +265,13 @@ @if (Model.Plan.Frequency == (int)PlanFrequency.Yearly) { - @Model.Plan.Cost.ToString("C", Cultures.UnitedStates)/@localizer["Year"] + @currencySymbol@Model.Plan.Cost.ToString("N2")/@localizer["Year"] } else { - @Model.Plan.Cost.ToString("C", Cultures.UnitedStates)/@localizer["month"] + @currencySymbol@Model.Plan.Cost.ToString("N2")/@localizer["month"] } @@ -527,43 +527,32 @@ function stripeCheckout(id) { const amount = slider == 1 ? val : $("#amount").val(); + const minAmount = IS_EU ? 10 : 20; - if (amount && amount > 10) { - const packs = (amount / 10) - 1; // First 10 users are free. + if (amount && amount >= minAmount && (!IS_EU ? amount > 10 : true)) { + const packs = IS_EU ? (amount / 10) : (amount / 10) - 1; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Subscription/GetStripeSession?id=' + id + '&count=' + packs, contentType: 'application/json', type: 'GET' }).done(function (data) { - if (data) { - if (data.SessionId) { - stripe.redirectToCheckout({ - sessionId: data.SessionId - }).then(function (result) { - // If `redirectToCheckout` fails due to a browser or network - // error, display the localized error message to your customer - // using `result.error.message`. - - swal({ - title: "Purchase Error", - text: "Error redirecting to Stripe for checkout. Stripe error: " + result.error.message, - icon: "error", - buttons: true, - dangerMode: false - }); - }); - } + if (data && data.SessionId) { + stripe.redirectToCheckout({ + sessionId: data.SessionId + }).then(function (result) { + if (result.error) { + swal({ title: "Purchase Error", text: "Error redirecting to Stripe: " + result.error.message, icon: "error", buttons: true, dangerMode: false }); + } + }); + } else { + swal({ title: "Checkout Error", text: "Unable to create a checkout session. Please try again.", icon: "error", buttons: true, dangerMode: false }); } + }).fail(function () { + swal({ title: "Connection Error", text: "Unable to reach the server. Please check your connection and try again.", icon: "error", buttons: true, dangerMode: false }); }); } else { - swal({ - title: "Cannot Purchase", - text: "Resgrid includes 10 entities for free for all departments. Please select a entity count greater then 10 to purchase.", - icon: "warning", - buttons: true, - dangerMode: false - }); + swal({ title: "Cannot Purchase", text: "Please select more entities to purchase a plan.", icon: "warning", buttons: true, dangerMode: false }); } } @@ -589,9 +578,10 @@ function paddleCheckout(id) { const amount = slider == 1 ? val : $("#amount").val(); + const minAmount = IS_EU ? 10 : 20; - if (amount && amount > 10) { - const packs = (amount / 10) - 1; + if (amount && amount >= minAmount && (!IS_EU ? amount > 10 : true)) { + const packs = IS_EU ? (amount / 10) : (amount / 10) - 1; $.ajax({ url: resgrid.absoluteBaseUrl + '/User/Subscription/GetPaddleCheckout?id=' + id + '&count=' + packs, @@ -600,24 +590,18 @@ }).done(function (data) { if (data) { if (data.HasActiveSub) { - swal({ - title: "Active Subscription", - text: "You already have an active subscription. Please manage your existing subscription instead.", - icon: "warning", - buttons: true, - dangerMode: false - }); + swal({ title: "Active Subscription", text: "You already have an active subscription. Please manage your existing subscription instead.", icon: "warning", buttons: true, dangerMode: false }); + return; + } + + if (!data.PriceId) { + swal({ title: "Checkout Error", text: "Unable to create a checkout session. Please try again.", icon: "error", buttons: true, dangerMode: false }); return; } var checkoutSettings = { - settings: { - successUrl: resgrid.absoluteBaseUrl + '/User/Subscription/PaddleProcessing?planId=' + id - }, - items: [{ - priceId: data.PriceId, - quantity: packs - }] + settings: { successUrl: resgrid.absoluteBaseUrl + '/User/Subscription/PaddleProcessing?planId=' + id }, + items: [{ priceId: data.PriceId, quantity: packs }] }; if (data.CustomerId) { @@ -625,16 +609,14 @@ } Paddle.Checkout.open(checkoutSettings); + } else { + swal({ title: "Checkout Error", text: "Unable to create a checkout session. Please try again.", icon: "error", buttons: true, dangerMode: false }); } + }).fail(function () { + swal({ title: "Connection Error", text: "Unable to reach the server. Please check your connection and try again.", icon: "error", buttons: true, dangerMode: false }); }); } else { - swal({ - title: "Cannot Purchase", - text: "Resgrid includes 10 entities for free for all departments. Please select a entity count greater then 10 to purchase.", - icon: "warning", - buttons: true, - dangerMode: false - }); + swal({ title: "Cannot Purchase", text: "Please select more entities to purchase a plan.", icon: "warning", buttons: true, dangerMode: false }); } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Subscription/SelectRegistrationPlan.cshtml b/Web/Resgrid.Web/Areas/User/Views/Subscription/SelectRegistrationPlan.cshtml index f34197cf..da75edaa 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Subscription/SelectRegistrationPlan.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Subscription/SelectRegistrationPlan.cshtml @@ -75,8 +75,8 @@ }

Select the number of Entities (Users + Units) you require using the slider or text box below. - @if (!isEU) { Your first 10 entities are included at no charge — each } else { Each } - additional pack of 10 entities is billed at the rate shown. Select Buy Yearly or Buy Monthly to proceed to checkout.

+ @if (!isEU) { Your first 10 entities are included at no charge — each additional } else { Each } + pack of 10 entities is billed at the rate shown. Select Buy Yearly or Buy Monthly to proceed to checkout.

diff --git a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml index ae398567..cc2f6f37 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WeatherAlerts/Settings.cshtml @@ -66,14 +66,25 @@
- - @localizer["AutoMessageSeverityHelp"] +

Configure which severity levels generate auto-messages and optionally restrict them to specific hours (department local time). Set both hours to 0 for 24-hour delivery.

+ + + + + + + + + + + + + + + + +
SeverityEnabledStart HourEnd Hour
@localizer["SeverityExtreme"]
@localizer["SeveritySevere"]
@localizer["SeverityModerate"]
@localizer["SeverityMinor"]
@localizer["SeverityUnknown"]
+
@@ -309,6 +320,14 @@ $('#settingMinSeverity').val(data.Data.MinimumSeverity); $('#settingAutoMsgSeverity').val(data.Data.AutoMessageSeverity); $('#settingCallIntegration').prop('checked', data.Data.CallIntegrationEnabled); + + if (data.Data.AutoMessageSchedule && data.Data.AutoMessageSchedule.length > 0) { + data.Data.AutoMessageSchedule.forEach(function (entry) { + $('.sched-enabled[data-severity="' + entry.Severity + '"]').prop('checked', entry.Enabled); + $('.sched-start[data-severity="' + entry.Severity + '"]').val(entry.StartHour); + $('.sched-end[data-severity="' + entry.Severity + '"]').val(entry.EndHour); + }); + } } }, error: function () { @@ -318,11 +337,23 @@ } function saveSettings() { + var schedule = []; + $('.sched-enabled').each(function () { + var sev = parseInt($(this).data('severity')); + schedule.push({ + Severity: sev, + Enabled: $(this).is(':checked'), + StartHour: parseInt($('.sched-start[data-severity="' + sev + '"]').val()) || 0, + EndHour: parseInt($('.sched-end[data-severity="' + sev + '"]').val()) || 0 + }); + }); + var data = { WeatherAlertsEnabled: $('#settingEnabled').is(':checked'), MinimumSeverity: parseInt($('#settingMinSeverity').val()), AutoMessageSeverity: parseInt($('#settingAutoMsgSeverity').val()), - CallIntegrationEnabled: $('#settingCallIntegration').is(':checked') + CallIntegrationEnabled: $('#settingCallIntegration').is(':checked'), + AutoMessageSchedule: schedule }; $.ajax({