diff --git a/backend/src/Notifo.Domain.Integrations.Abstractions/MobilePushMessage.cs b/backend/src/Notifo.Domain.Integrations.Abstractions/MobilePushMessage.cs index eaa0ab72..ccb4747d 100644 --- a/backend/src/Notifo.Domain.Integrations.Abstractions/MobilePushMessage.cs +++ b/backend/src/Notifo.Domain.Integrations.Abstractions/MobilePushMessage.cs @@ -19,6 +19,8 @@ public sealed class MobilePushMessage : BaseMessage public string? Body { get; init; } + public string? ConfirmLink { get; init; } + public string? ConfirmText { get; init; } public string? ConfirmUrl { get; init; } diff --git a/backend/src/Notifo.Domain.Integrations/Firebase/UserNotificationExtensions.cs b/backend/src/Notifo.Domain.Integrations/Firebase/UserNotificationExtensions.cs index 15a207fb..5537e559 100644 --- a/backend/src/Notifo.Domain.Integrations/Firebase/UserNotificationExtensions.cs +++ b/backend/src/Notifo.Domain.Integrations/Firebase/UserNotificationExtensions.cs @@ -45,6 +45,7 @@ public static Message ToFirebaseMessage(this MobilePushMessage source, DateTimeO message.Data = new Dictionary() .WithNonEmpty("id", source.NotificationId.ToString()) + .WithNonEmpty("confirmLink", source.ConfirmLink) .WithNonEmpty("confirmText", source.ConfirmText) .WithNonEmpty("confirmUrl", source.ConfirmUrl) .WithNonEmpty("isConfirmed", source.IsConfirmed.ToString()) diff --git a/backend/src/Notifo.Domain/Channels/ChannelExtensions.cs b/backend/src/Notifo.Domain/Channels/ChannelExtensions.cs index 8139ae02..992c6973 100644 --- a/backend/src/Notifo.Domain/Channels/ChannelExtensions.cs +++ b/backend/src/Notifo.Domain/Channels/ChannelExtensions.cs @@ -22,16 +22,6 @@ public static string HtmlTrackingLink(this BaseUserNotification notification, Gu return trackingLink; } - public static string? ConfirmText(this BaseUserNotification notification) - { - return notification.Formatting.ConfirmText; - } - - public static string? ConfirmUrl(this BaseUserNotification notification) - { - return notification.ConfirmUrl; - } - public static string? ImageSmall(this BaseUserNotification notification, IImageFormatter imageFormatter, string preset) { return imageFormatter.AddPreset(notification.Formatting.ImageSmall, preset); diff --git a/backend/src/Notifo.Domain/Channels/MobilePush/MobilePushChannel.cs b/backend/src/Notifo.Domain/Channels/MobilePush/MobilePushChannel.cs index da5f388b..4984abff 100644 --- a/backend/src/Notifo.Domain/Channels/MobilePush/MobilePushChannel.cs +++ b/backend/src/Notifo.Domain/Channels/MobilePush/MobilePushChannel.cs @@ -281,6 +281,7 @@ private MobilePushMessage BuildMessage(MobilePushJob job) { Subject = notification.Formatting?.Subject, Body = notification.Formatting?.Body, + ConfirmLink = notification.Formatting?.ConfirmLink, ConfirmText = notification.Formatting?.ConfirmText, ConfirmUrl = notification.ComputeConfirmUrl(Providers.MobilePush, job.ConfigurationId), Data = notification.Data, diff --git a/backend/src/Notifo.Domain/Channels/WebPush/WebPushChannel.cs b/backend/src/Notifo.Domain/Channels/WebPush/WebPushChannel.cs index 24edd3a9..f8ad623b 100644 --- a/backend/src/Notifo.Domain/Channels/WebPush/WebPushChannel.cs +++ b/backend/src/Notifo.Domain/Channels/WebPush/WebPushChannel.cs @@ -67,6 +67,11 @@ public override IEnumerable GetConfigurations(UserNotificatio public override async Task SendAsync(UserNotification notification, ChannelContext context, CancellationToken ct) { + if (context.IsUpdate) + { + return; + } + if (!context.Configuration.TryGetValue(Endpoint, out var endpoint)) { // Old configuration without a mobile push token. diff --git a/backend/src/Notifo.Domain/Channels/WebPush/WebPushPayload.cs b/backend/src/Notifo.Domain/Channels/WebPush/WebPushPayload.cs index 30442ac4..2c3dde35 100644 --- a/backend/src/Notifo.Domain/Channels/WebPush/WebPushPayload.cs +++ b/backend/src/Notifo.Domain/Channels/WebPush/WebPushPayload.cs @@ -20,6 +20,9 @@ public sealed class WebPushPayload [JsonPropertyName("cu")] public string? ConfirmUrl { get; set; } + [JsonPropertyName("cl")] + public string? ConfirmLink { get; set; } + [JsonPropertyName("ct")] public string? ConfirmText { get; set; } @@ -61,7 +64,6 @@ public static WebPushPayload Create(UserNotification notification, Guid configur SimpleMapper.Map(notification.Formatting, result); // Compute the tracking links afterwards because the mapping would override it. - result.ConfirmText = notification.Formatting.ConfirmText; result.ConfirmUrl = notification.ComputeConfirmUrl(Providers.WebPush, configurationId); result.TrackSeenUrl = notification.ComputeTrackSeenUrl(Providers.WebPush, configurationId); result.TrackDeliveredUrl = notification.ComputeTrackDeliveredUrl(Providers.WebPush, configurationId); diff --git a/backend/src/Notifo.Domain/NotificationFormatting.cs b/backend/src/Notifo.Domain/NotificationFormatting.cs index 515cc7cd..4a25af42 100644 --- a/backend/src/Notifo.Domain/NotificationFormatting.cs +++ b/backend/src/Notifo.Domain/NotificationFormatting.cs @@ -61,6 +61,7 @@ public static NotificationFormatting Clone(this NotificationFormatting { Body = source.Body, + ConfirmLink = source.ConfirmLink, ConfirmMode = source.ConfirmMode, ConfirmText = source.ConfirmText, ImageLarge = source.ImageLarge, @@ -76,6 +77,7 @@ public static NotificationFormatting MergedWith(this Notification return new NotificationFormatting { Body = Merged(source.Body, other?.Body), + ConfirmLink = Merged(source.ConfirmLink, other?.ConfirmLink), ConfirmMode = source.ConfirmMode ?? other?.ConfirmMode, ConfirmText = Merged(source.ConfirmText, other?.ConfirmText), ImageLarge = Merged(source.ImageLarge, other?.ImageLarge), @@ -91,6 +93,7 @@ public static NotificationFormatting Clone(this NotificationForma return new NotificationFormatting { Body = source.Body?.Clone(), + ConfirmLink = source.ConfirmLink?.Clone(), ConfirmMode = source.ConfirmMode, ConfirmText = source.ConfirmText?.Clone(), ImageLarge = source.ImageLarge?.Clone(), @@ -108,6 +111,7 @@ public static NotificationFormatting Transform(this Notificatio var result = new NotificationFormatting { Body = transform(formatting.Body), + ConfirmLink = transform(formatting.ConfirmLink), ConfirmMode = formatting.ConfirmMode, ConfirmText = transform(formatting.ConfirmText), ImageLarge = transform(formatting.ImageLarge), diff --git a/backend/src/Notifo.Domain/NotificationFormatting{TText}.cs b/backend/src/Notifo.Domain/NotificationFormatting{TText}.cs index 3d65ee7b..ed13184f 100644 --- a/backend/src/Notifo.Domain/NotificationFormatting{TText}.cs +++ b/backend/src/Notifo.Domain/NotificationFormatting{TText}.cs @@ -13,6 +13,8 @@ public sealed class NotificationFormatting where TText : class public TText? Body { get; set; } + public TText? ConfirmLink { get; set; } + public TText? ConfirmText { get; set; } public TText? ImageSmall { get; set; } diff --git a/backend/src/Notifo/Areas/Api/Controllers/NotificationFormattingDto.cs b/backend/src/Notifo/Areas/Api/Controllers/NotificationFormattingDto.cs index 8d8dbff9..1abee728 100644 --- a/backend/src/Notifo/Areas/Api/Controllers/NotificationFormattingDto.cs +++ b/backend/src/Notifo/Areas/Api/Controllers/NotificationFormattingDto.cs @@ -27,6 +27,11 @@ public sealed class NotificationFormattingDto /// public LocalizedText? Body { get; set; } + /// + /// The optional confirm link with one entry per language. + /// + public LocalizedText? ConfirmLink { get; set; } + /// /// The optional confirm text with one entry per language. /// diff --git a/backend/src/Notifo/Areas/Api/Controllers/Notifications/Dtos/UserNotificationBaseDto.cs b/backend/src/Notifo/Areas/Api/Controllers/Notifications/Dtos/UserNotificationBaseDto.cs index 0ed7f848..880e5eee 100644 --- a/backend/src/Notifo/Areas/Api/Controllers/Notifications/Dtos/UserNotificationBaseDto.cs +++ b/backend/src/Notifo/Areas/Api/Controllers/Notifications/Dtos/UserNotificationBaseDto.cs @@ -77,6 +77,11 @@ public abstract class UserNotificationBaseDto /// public string? LinkText { get; set; } + /// + /// The link after the confirm button. + /// + public string? ConfirmLink { get; set; } + /// /// The text for the confirm button. /// diff --git a/backend/src/Notifo/Areas/Api/Controllers/Tracking/TrackingController.cs b/backend/src/Notifo/Areas/Api/Controllers/Tracking/TrackingController.cs index 33e69b5d..7858373d 100644 --- a/backend/src/Notifo/Areas/Api/Controllers/Tracking/TrackingController.cs +++ b/backend/src/Notifo/Areas/Api/Controllers/Tracking/TrackingController.cs @@ -10,6 +10,7 @@ using Notifo.Domain; using Notifo.Domain.Apps; using Notifo.Domain.UserNotifications; +using Notifo.Infrastructure; namespace Notifo.Areas.Api.Controllers.Tracking; @@ -76,22 +77,29 @@ public async Task Confirm(string id, [FromQuery] TrackingQueryDto return View(); } - var app = await appStore.GetCachedAsync(notification.AppId, HttpContext.RequestAborted); - - if (app?.ConfirmUrl != null && Uri.IsWellFormedUriString(app.ConfirmUrl, UriKind.Absolute)) + static bool TryGetLink(string? url, string id, out IActionResult result) { - var url = app.ConfirmUrl!; + result = null!; - if (url.Contains('?', StringComparison.OrdinalIgnoreCase)) - { - url += $"&id={id}"; - } - else + if (url == null || !Uri.IsWellFormedUriString(url, UriKind.Absolute)) { - url += $"?id={id}"; + return false; } - return Redirect(url); + result = new RedirectResult(url.AppendQueries("id", id)); + return true; + } + + if (TryGetLink(notification.Formatting.ConfirmLink, id, out var redirect)) + { + return redirect; + } + + var app = await appStore.GetCachedAsync(notification.AppId, HttpContext.RequestAborted); + + if (TryGetLink(app?.ConfirmUrl, id, out redirect)) + { + return redirect; } return View(); diff --git a/frontend/src/app/service/service.ts b/frontend/src/app/service/service.ts index 893704fc..2192502a 100644 --- a/frontend/src/app/service/service.ts +++ b/frontend/src/app/service/service.ts @@ -6283,6 +6283,8 @@ export interface NotificationFormattingDto { subject: LocalizedText; /** The optional body with one entry per language. */ body?: LocalizedText | undefined; + /** The optional confirm link with one entry per language. */ + confirmlink?: LocalizedText | undefined; /** The optional confirm text with one entry per language. */ confirmText?: LocalizedText | undefined; /** The optional small image with one entry per language. */ @@ -6390,6 +6392,8 @@ export interface UserNotificationBaseDto { linkUrl?: string | undefined; /** The link text. */ linkText?: string | undefined; + /** The link after the confirm button. */ + confirmLink?: string | undefined; /** The text for the confirm button. */ confirmText?: string | undefined; /** The tracking url that needs to be invoked to mark the notification as confirmed. */ diff --git a/frontend/src/app/shared/components/NotificationSettingsForm.tsx b/frontend/src/app/shared/components/NotificationSettingsForm.tsx index 4fedab51..e415c379 100644 --- a/frontend/src/app/shared/components/NotificationSettingsForm.tsx +++ b/frontend/src/app/shared/components/NotificationSettingsForm.tsx @@ -71,6 +71,9 @@ export module NotificationsForm { + + diff --git a/frontend/src/app/texts/en.ts b/frontend/src/app/texts/en.ts index d2b07d8f..32874a03 100644 --- a/frontend/src/app/texts/en.ts +++ b/frontend/src/app/texts/en.ts @@ -57,6 +57,8 @@ export const EN = { Inherit: '-', }, confirm: 'Confirm', + confirmLink: 'Confirm Link', + confirmLinkHints: 'The URL that will be opened after the button has been pressed.', confirmMode: 'Confirm Mode', confirmModeHints: 'In Explicit mode a button will be shown to confirm the notification.', confirmModes: { diff --git a/frontend/src/sdk/demo.html b/frontend/src/sdk/demo.html index 4a9ca347..7fd6bc2e 100644 --- a/frontend/src/sdk/demo.html +++ b/frontend/src/sdk/demo.html @@ -126,7 +126,11 @@

notifo Demo

apiUrl: '/', onNotification: (notification) => { - console.log(JSON.stringify(notification)); + console.log(`Received: ${JSON.stringify(notification, 0, 2)}`); + }, + + onConfirm: (notification) => { + console.log(`Confirmed: ${JSON.stringify(notification, 0, 2)}`); }, linkTarget: '_blank', diff --git a/frontend/src/sdk/push/index.ts b/frontend/src/sdk/push/index.ts index 6c74c4b7..8aee3ad1 100644 --- a/frontend/src/sdk/push/index.ts +++ b/frontend/src/sdk/push/index.ts @@ -19,11 +19,10 @@ export module PUSH { } const serviceWorker = await registerServiceWorker(config, options); - - const simpleConfig = buildConfig(config); + const serviceConfig = buildConfig(config); if (serviceWorker.active) { - serviceWorker.active.postMessage({ type: 'subscribe', config: simpleConfig }); + serviceWorker.active.postMessage({ type: 'subscribe', config: serviceConfig }); } } @@ -34,11 +33,10 @@ export module PUSH { } const serviceWorker = await navigator.serviceWorker.ready; - - const simpleConfig = buildConfig(config); + const serviceConfig = buildConfig(config); if (serviceWorker.active) { - serviceWorker.active.postMessage({ type: 'unsubscribe', config: simpleConfig }); + serviceWorker.active.postMessage({ type: 'unsubscribe', config: serviceConfig }); } } @@ -108,7 +106,6 @@ async function registerServiceWorker(config: SDKConfig, options?: SubscribeOptio return await navigator.serviceWorker.ready; } else { const serviceWorker = await navigator.serviceWorker.register(config.serviceWorkerUrl); - await serviceWorker.update(); return serviceWorker; diff --git a/frontend/src/sdk/sdk-worker.ts b/frontend/src/sdk/sdk-worker.ts index a99adcaa..ec780e23 100644 --- a/frontend/src/sdk/sdk-worker.ts +++ b/frontend/src/sdk/sdk-worker.ts @@ -24,16 +24,24 @@ import { apiDeleteWebPush, apiPostWebPush, logWarn, NotifoNotificationDto, parse } if (event.action === 'confirm') { + if (notification.confirmLink && self.clients.openWindow) { + const promise = self.clients.openWindow(notification.confirmLink); + + event.waitUntil(promise); + } + if (notification.confirmUrl) { const promise = fetch(notification.confirmUrl); event.waitUntil(promise); } - } else if (notification.linkUrl && self.clients.openWindow) { - const promise = self.clients.openWindow(notification.linkUrl); + } else { + if (notification.linkUrl && self.clients.openWindow) { + const promise = self.clients.openWindow(notification.linkUrl); - event.notification.close(); - event.waitUntil(promise); + event.notification.close(); + event.waitUntil(promise); + } } }); @@ -68,7 +76,7 @@ import { apiDeleteWebPush, apiPostWebPush, logWarn, NotifoNotificationDto, parse self.addEventListener('push', event => { if (event.data) { - const notification: NotifoNotificationDto = parseShortNotification(event.data.json()); + const notification = parseShortNotification(event.data.json()); const promise = showNotification(self, notification); @@ -127,8 +135,9 @@ async function showNotification(self: ServiceWorkerGlobalScope, notification: No options.tag = notification.id; if (notification.confirmUrl && notification.confirmText && !notification.isConfirmed) { - options.actions = [{ action: 'confirm', title: notification.confirmText }]; options.requireInteraction = true; + options.actions ||= []; + options.actions.push({ action: 'confirm', title: notification.confirmText }); } if (notification.body) { diff --git a/frontend/src/sdk/sdk.ts b/frontend/src/sdk/sdk.ts index 8e648103..05d8c5b1 100644 --- a/frontend/src/sdk/sdk.ts +++ b/frontend/src/sdk/sdk.ts @@ -38,11 +38,11 @@ const instance = { break; } case 'hide-notifications': { - queueJobs.enqueue((() => UI.destroy(args[1]))); + queueJobs.enqueue((() => UI.release(args[1]))); break; } case 'hide-topic': { - queueJobs.enqueue((() => UI.destroy(args[1]))); + queueJobs.enqueue((() => UI.release(args[1]))); break; } case 'show-notifications': { @@ -77,7 +77,7 @@ const instance = { return Promise.resolve(false); } - if (await PUSH.isPending() && !await UI.askForWebPush(queueInit.config)) { + if (await PUSH.isPending() && !await UI.askForWebPush(queueInit.config, args[1])) { return false; } diff --git a/frontend/src/sdk/shared/api.ts b/frontend/src/sdk/shared/api.ts index b243e343..fecc2262 100644 --- a/frontend/src/sdk/shared/api.ts +++ b/frontend/src/sdk/shared/api.ts @@ -43,6 +43,9 @@ export interface NotifoNotificationDto { // The confirm url. confirmUrl?: string; + // The confirm link. + confirmLink?: string; + // The confirm text. confirmText?: string; @@ -129,6 +132,7 @@ export function parseShortNotification(value: any): NotifoNotificationDto { return { id: value.id, body: value.nb, + confirmLink: value.cl, confirmText: value.ct, confirmUrl: value.cu, imageLarge: value.il, diff --git a/frontend/src/sdk/shared/config.ts b/frontend/src/sdk/shared/config.ts index 29343525..6893446e 100644 --- a/frontend/src/sdk/shared/config.ts +++ b/frontend/src/sdk/shared/config.ts @@ -6,7 +6,7 @@ */ import { de, enUS } from 'date-fns/locale'; -import { isNumber, isObject, isString, isUndefined, logWarn } from './utils'; +import { isFunction, isNumber, isObject, isString, isUndefined, logWarn } from './utils'; export const SUPPORTED_LOCALES = { en: enUS, @@ -196,6 +196,10 @@ export function buildSDKConfig(opts: SDKConfig, scriptLocation: string | null | logWarn('init.userLanguage must be a string if defined.'); } + if (!isFunctionOption(options.onNotification)) { + logWarn('init.onNotification must be a function if defined.'); + } + if (!isLocaleOption(options.locale)) { logWarn(`init.locale must be a valid locale. Allowed: ${Object.keys(SUPPORTED_LOCALES).join(',')}`); options.locale = undefined!; @@ -304,6 +308,10 @@ function isStringOption(value: any) { return !value || isString(value); } +function isFunctionOption(value: any) { + return !value || isFunction(value); +} + function isEnumOption(value: any, allowed: ReadonlyArray) { return !value || allowed.indexOf(value) >= 0; } @@ -370,12 +378,18 @@ export interface SDKConfig { // All needed texts. texts: Texts; + // Shown when the notification is confirmed. + onConfirm?: (notification: any) => void; + // A callback that is invoked when a notification is retrieved. onNotification?: (notification: any) => void; } export interface SubscribeOptions { existingWorker?: true; + + // The render function. + onSubscribeDialog?: (config: SDKConfig, allow: () => void, deny: () => void) => void; } type OptionMainStyle = 'message' | 'chat' | 'chat_filled' | 'notifo'; diff --git a/frontend/src/sdk/ui/components/NotificationItem.tsx b/frontend/src/sdk/ui/components/NotificationItem.tsx index e468f68f..63da8373 100644 --- a/frontend/src/sdk/ui/components/NotificationItem.tsx +++ b/frontend/src/sdk/ui/components/NotificationItem.tsx @@ -62,6 +62,18 @@ export const NotificationItem = (props: NotificationItemProps) => { const inView = useInView(ref, modal); + const target = useMemo(() => { + if (config.linkTarget) { + return config.linkTarget; + } + + if (!notification.linkUrl || getHostName(notification.linkUrl) === window.location.host) { + return '_self'; + } + + return '_blank'; + }, [config.linkTarget, notification.linkUrl]); + const doSee = useCallback(async () => { if (!onSeen) { return; @@ -92,7 +104,11 @@ export const NotificationItem = (props: NotificationItemProps) => { } catch { setMarkConfirm('Failed'); } - }, [notification, onConfirm]); + + if (notification.confirmLink) { + window.open(notification.confirmLink, target); + } + }, [notification, onConfirm, target]); const doDelete = useCallback(async () => { if (!onDelete) { @@ -132,18 +148,6 @@ export const NotificationItem = (props: NotificationItemProps) => { return formatDistanceToNow(parseISO(notification.created), { locale }); }, [config.locale, notification.created]); - const target = useMemo(() => { - if (config.linkTarget) { - return config.linkTarget; - } - - if (!notification.linkUrl || getHostName(notification.linkUrl) === window.location.host) { - return '_self'; - } - - return '_blank'; - }, [config.linkTarget, notification.linkUrl]); - return (
diff --git a/frontend/src/sdk/ui/index.tsx b/frontend/src/sdk/ui/index.tsx index d7d0f848..0321b228 100644 --- a/frontend/src/sdk/ui/index.tsx +++ b/frontend/src/sdk/ui/index.tsx @@ -6,7 +6,7 @@ */ import { render } from 'preact'; -import { buildNotificationsOptions, buildTopicOptions, isString, loadStyle, logError, NotificationsOptions, SDKConfig, TopicOptions } from '@sdk/shared'; +import { buildNotificationsOptions, buildTopicOptions, isFunction, isString, loadStyle, logError, NotificationsOptions, SDKConfig, SubscribeOptions, TopicOptions } from '@sdk/shared'; import { renderNotificationsUI, renderTopicUI, renderWebPushUI } from './components'; @@ -47,7 +47,7 @@ export module UI { renderTopicUI(element, topic, options, config); } - export async function askForWebPush(config: SDKConfig): Promise { + export async function askForWebPush(config: SDKConfig, options?: SubscribeOptions): Promise { if (config.styleUrl) { await loadStyle(config.styleUrl); } @@ -88,22 +88,25 @@ export module UI { const doAllow = () => { resolve(true); - - destroy(element); + release(element); }; const doDeny = () => { resolve(false); + release(element); denied(); - destroy(element); }; - renderWebPushUI(element, config, doAllow, doDeny); + if (isFunction(options?.onSubscribeDialog)) { + options?.onSubscribeDialog(config, doAllow, doDeny); + } else { + renderWebPushUI(element, config, doAllow, doDeny); + } }); } - export function destroy(elementOrId: string | HTMLElement) { + export function release(elementOrId: string | HTMLElement) { const element = findElement(elementOrId); if (!element) { diff --git a/tools/sdk/Notifo.SDK/Generated.cs b/tools/sdk/Notifo.SDK/Generated.cs index ac16983e..0b5016ff 100644 --- a/tools/sdk/Notifo.SDK/Generated.cs +++ b/tools/sdk/Notifo.SDK/Generated.cs @@ -14282,6 +14282,12 @@ public partial class NotificationFormattingDto [Newtonsoft.Json.JsonProperty("body", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public LocalizedText Body { get; set; } + /// + /// The optional confirm link with one entry per language. + /// + [Newtonsoft.Json.JsonProperty("confirmlink", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] + public LocalizedText Confirmlink { get; set; } + /// /// The optional confirm text with one entry per language. /// @@ -14757,6 +14763,12 @@ public abstract partial class UserNotificationBaseDto [Newtonsoft.Json.JsonProperty("linkText", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] public string LinkText { get; set; } + /// + /// The link after the confirm button. + /// + [Newtonsoft.Json.JsonProperty("confirmLink", Required = Newtonsoft.Json.Required.Default, NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore)] + public string ConfirmLink { get; set; } + /// /// The text for the confirm button. ///