Handling of paddle purchases and add-ons.

This commit is contained in:
Burak Kaan Köse
2026-03-19 01:50:14 +01:00
parent f306f6eb1c
commit b0ee5c9974
26 changed files with 779 additions and 513 deletions
+1
View File
@@ -141,6 +141,7 @@ private string searchQuery = string.Empty;
- Wrap async operations in try-catch
- Log errors via IWinoLogger
- For dependency properties in WinUI code, always prefer `[GeneratedDependencyProperty]` from CommunityToolkit over manual `DependencyProperty.Register(...)` declarations.
- When a `[RelayCommand]` needs enable/disable logic, prefer the command's `CanExecute` over binding `Button.IsEnabled` in XAML; use `[NotifyCanExecuteChangedFor]` on dependent properties and call `NotifyCanExecuteChanged()` explicitly when non-generated state affects the command.
- In ViewModels, update all UI-bound properties/collections via `ExecuteUIThread(...)` (especially after awaited calls and any use of `ConfigureAwait(false)`).
- ViewModels should only handle UI interaction/state and delegate business logic to services; account-management work belongs in `WinoAccountProfileService`, and preferences import/export/apply logic belongs in `PreferencesService`.
- In `EventDetailsPageViewModel.LoadAttendeesAsync`, never mutate `CurrentEvent.Attendees` outside `ExecuteUIThread(...)`.
@@ -0,0 +1,7 @@
namespace Wino.Core.Domain.Enums;
public enum WinoAddOnProductType
{
AI_PACK,
UNLIMITED_ACCOUNTS
}
@@ -1,7 +1,5 @@
using System.Threading.Tasks;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Store;
namespace Wino.Core.Domain.Interfaces;
public interface IStoreManagementService
@@ -9,10 +7,10 @@ public interface IStoreManagementService
/// <summary>
/// Checks whether user has the type of an add-on purchased.
/// </summary>
Task<bool> HasProductAsync(StoreProductType productType);
Task<bool> HasProductAsync(WinoAddOnProductType productType);
/// <summary>
/// Attempts to purchase the given add-on.
/// </summary>
Task<StorePurchaseResult> PurchaseAsync(StoreProductType productType);
Task<StorePurchaseResult> PurchaseAsync(WinoAddOnProductType productType);
}
@@ -2,21 +2,25 @@
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Billing;
using Wino.Mail.Api.Contracts.Common;
namespace Wino.Core.Domain.Interfaces;
public interface IWinoAccountApiClient
{
Task<ApiEnvelope<AuthResultDto>> RegisterAsync(string email, string password, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AuthResultDto>> LoginAsync(string email, string password, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AuthResultDto>> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default);
Task<WinoAccountApiResult<AuthResultDto>> RegisterAsync(string email, string password, CancellationToken cancellationToken = default);
Task<WinoAccountApiResult<AuthResultDto>> LoginAsync(string email, string password, CancellationToken cancellationToken = default);
Task<WinoAccountApiResult<AuthResultDto>> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default);
Task<ApiEnvelope<JsonElement>> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<string>> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default);
Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
Task<ApiEnvelope<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default);
Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default);
Task<bool> SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default);
}
@@ -2,9 +2,11 @@
using System.Threading;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Billing;
using Wino.Mail.Api.Contracts.Common;
namespace Wino.Core.Domain.Interfaces;
@@ -17,8 +19,10 @@ public interface IWinoAccountProfileService
Task<WinoAccount?> GetActiveAccountAsync();
Task<WinoAccount?> GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default);
Task<bool> HasActiveAccountAsync();
Task<bool> HasAddOnAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<string>> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default);
Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
Task<ApiEnvelope<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default);
Task SignOutAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,12 @@
#nullable enable
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Wino.Core.Domain.Models.Accounts;
namespace Wino.Core.Domain.Interfaces;
public interface IWinoAddOnService
{
Task<IReadOnlyList<WinoAddOnInfo>> GetAvailableAddOnsAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,26 @@
#nullable enable
namespace Wino.Core.Domain.Models.Accounts;
public sealed class WinoAccountApiResult<T>
{
public bool IsSuccess { get; init; }
public string? ErrorCode { get; init; }
public string? ErrorMessage { get; init; }
public T? Result { get; init; }
public static WinoAccountApiResult<T> Success(T result)
=> new()
{
IsSuccess = true,
Result = result
};
public static WinoAccountApiResult<T> Failure(string? errorCode, string? errorMessage = null)
=> new()
{
IsSuccess = false,
ErrorCode = errorCode,
ErrorMessage = errorMessage
};
}
@@ -7,6 +7,7 @@ public sealed class WinoAccountOperationResult
{
public bool IsSuccess { get; init; }
public string? ErrorCode { get; init; }
public string? ErrorMessage { get; init; }
public WinoAccount? Account { get; init; }
public static WinoAccountOperationResult Success(WinoAccount account)
@@ -16,10 +17,11 @@ public sealed class WinoAccountOperationResult
Account = account
};
public static WinoAccountOperationResult Failure(string? errorCode)
public static WinoAccountOperationResult Failure(string? errorCode, string? errorMessage = null)
=> new()
{
IsSuccess = false,
ErrorCode = errorCode
ErrorCode = errorCode,
ErrorMessage = errorMessage
};
}
@@ -0,0 +1,12 @@
using System;
using Wino.Core.Domain.Enums;
namespace Wino.Core.Domain.Models.Accounts;
public sealed record WinoAddOnInfo(
WinoAddOnProductType ProductType,
bool IsPurchased,
int? UsageCount = null,
int? UsageLimit = null,
double UsagePercentage = 0,
DateTimeOffset? RenewalDateUtc = null);
@@ -1,6 +0,0 @@
namespace Wino.Core.Domain.Models.Store;
public enum StoreProductType
{
UnlimitedAccounts
}
@@ -84,6 +84,7 @@
"Buttons_SyncAliases": "Synchronize Aliases",
"Buttons_TryAgain": "Try Again",
"Buttons_Yes": "Yes",
"Purchased": "Purchased",
"Sync_SynchronizingFolder": "Synchronizing {0} {1}%",
"Sync_DownloadedMessages": "Downloaded {0} messages from {1}",
"SyncAction_Archiving": "Archiving {0} mail(s)",
@@ -860,7 +861,7 @@
"WinoAccount_Management_SignedOutTitle": "Sign in to Wino Mail",
"WinoAccount_Management_SignedOutDescription": "Sign in or create an account to sync your email, access AI features, and manage your settings across devices.",
"WinoAccount_Management_ProfileSectionHeader": "Profile",
"WinoAccount_Management_AiPackSectionHeader": "AI Pack",
"WinoAccount_Management_AddOnsSectionHeader": "Wino Add-Ons",
"WinoAccount_Management_DataSectionHeader": "Data",
"WinoAccount_Management_AccountActionsSectionHeader": "Account actions",
"WinoAccount_Management_AccountCardTitle": "Account",
@@ -887,14 +888,10 @@
"WinoAccount_Management_AiPackFeatureTranslate": "Translate",
"WinoAccount_Management_AiPackFeatureRewrite": "Rewrite",
"WinoAccount_Management_AiPackFeatureSummarize": "Summarize",
"WinoAccount_Management_ImportSettingsTitle": "Import settings",
"WinoAccount_Management_ImportSettingsDescription": "Restore your preferences from cloud.",
"WinoAccount_Management_ExportSettingsTitle": "Export settings",
"WinoAccount_Management_ExportSettingsDescription": "Save your preferences to cloud.",
"WinoAccount_Management_SyncPreferencesTitle": "Synchronize Preferences",
"WinoAccount_Management_SyncPreferencesDescription": "Import or export your preferences to cloud. Import them across devices.",
"WinoAccount_Management_SignOutTitle": "Sign out",
"WinoAccount_Management_SignOutDescription": "Sign out of your account on this device",
"WinoAccount_Management_SettingsCardTitle": "Settings sync",
"WinoAccount_Management_SettingsCardDescription": "Export your current settings to your Wino Account or import them back on this device.",
"WinoAccount_Management_StatusLabel": "Status: {0}",
"WinoAccount_Management_NoRemoteSettings": "There are no synchronized settings stored for this account yet.",
"WinoAccount_Management_ExportSucceeded": "Your settings were exported to your Wino Account.",
@@ -905,6 +902,12 @@
"WinoAccount_Management_ImportEmpty": "The synchronized settings payload does not contain any values to restore.",
"WinoAccount_Management_LoadFailed": "Wino could not load the latest Wino Account information.",
"WinoAccount_Management_ActionFailed": "The Wino Account request could not be completed.",
"WinoAddOn_AI_PACK_Name": "AI Pack",
"WinoAddOn_AI_PACK_Description": "Translate, rewrite, and summarize emails with Wino AI.",
"WinoAddOn_AI_PACK_Keywords": "Translate · Rewrite · Summarize",
"WinoAddOn_UNLIMITED_ACCOUNTS_Name": "Unlimited Accounts",
"WinoAddOn_UNLIMITED_ACCOUNTS_Description": "Remove the 3-account limit and add as many accounts as you need.",
"WinoAddOn_UNLIMITED_ACCOUNTS_Keywords": "More accounts · No limit · One-time purchase",
"SettingsSearch_MessageList_Keywords": "message;messages;list;threading;threads;avatar;preview;sender",
"SettingsSearch_ReadComposePane_Keywords": "reader;compose;composer;font;fonts;external content;display;reading",
"SettingsSearch_SignatureAndEncryption_Keywords": "signature;signatures;encryption;certificate;certificates;s mime;smime;security",
@@ -3,7 +3,9 @@ using System.Threading.Tasks;
using FluentAssertions;
using Moq;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Common;
using Wino.Services;
@@ -15,6 +17,7 @@ namespace Wino.Core.Tests.Services;
public class WinoAccountProfileServiceTests : IAsyncLifetime
{
private readonly Mock<IWinoAccountApiClient> _apiClient = new();
private readonly Mock<IStoreManagementService> _storeManagementService = new();
private InMemoryDatabaseService _databaseService = null!;
private WinoAccountProfileService _service = null!;
@@ -22,7 +25,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
{
_databaseService = new InMemoryDatabaseService();
await _databaseService.InitializeAsync();
_service = new WinoAccountProfileService(_databaseService, _apiClient.Object);
_service = new WinoAccountProfileService(_databaseService, _apiClient.Object, _storeManagementService.Object);
}
public async Task DisposeAsync()
@@ -37,7 +40,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
_apiClient
.Setup(x => x.LoginAsync("first@example.com", "pw", default))
.ReturnsAsync(ApiEnvelope<AuthResultDto>.Success(authResult));
.ReturnsAsync(WinoAccountApiResult<AuthResultDto>.Success(authResult));
var result = await _service.LoginAsync("first@example.com", "pw");
@@ -56,11 +59,11 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
{
_apiClient
.Setup(x => x.LoginAsync("first@example.com", "pw", default))
.ReturnsAsync(ApiEnvelope<AuthResultDto>.Success(CreateAuthResult("first@example.com")));
.ReturnsAsync(WinoAccountApiResult<AuthResultDto>.Success(CreateAuthResult("first@example.com")));
_apiClient
.Setup(x => x.LoginAsync("second@example.com", "pw", default))
.ReturnsAsync(ApiEnvelope<AuthResultDto>.Success(CreateAuthResult("second@example.com")));
.ReturnsAsync(WinoAccountApiResult<AuthResultDto>.Success(CreateAuthResult("second@example.com")));
await _service.LoginAsync("first@example.com", "pw");
await _service.LoginAsync("second@example.com", "pw");
@@ -77,7 +80,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
_apiClient
.Setup(x => x.LoginAsync("signout@example.com", "pw", default))
.ReturnsAsync(ApiEnvelope<AuthResultDto>.Success(authResult));
.ReturnsAsync(WinoAccountApiResult<AuthResultDto>.Success(authResult));
_apiClient
.Setup(x => x.LogoutAsync(authResult.RefreshToken, default))
@@ -90,6 +93,32 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
persisted.Should().BeEmpty();
}
[Fact]
public async Task HasAddOnAsync_ShouldUseLegacyStoreForUnlimitedAccounts()
{
_storeManagementService
.Setup(x => x.HasProductAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS))
.ReturnsAsync(true);
var hasAddOn = await _service.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS);
hasAddOn.Should().BeTrue();
}
[Fact]
public async Task LoginAsync_ShouldPreserveEnvelopeErrorMessage()
{
_apiClient
.Setup(x => x.LoginAsync("first@example.com", "pw", default))
.ReturnsAsync(WinoAccountApiResult<AuthResultDto>.Failure(ApiErrorCodes.InvalidCredentials, "Password does not match this account."));
var result = await _service.LoginAsync("first@example.com", "pw");
result.IsSuccess.Should().BeFalse();
result.ErrorCode.Should().Be(ApiErrorCodes.InvalidCredentials);
result.ErrorMessage.Should().Be("Password does not match this account.");
}
private static AuthResultDto CreateAuthResult(string email)
{
return new AuthResultDto(
@@ -9,7 +9,6 @@ using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Store;
using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Navigation;
@@ -44,6 +43,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
protected IAccountService AccountService { get; }
protected IProviderService ProviderService { get; }
protected IStoreManagementService StoreManagementService { get; }
protected IWinoAccountProfileService WinoAccountProfileService { get; }
protected IAuthenticationProvider AuthenticationProvider { get; }
protected IPreferencesService PreferencesService { get; }
@@ -52,6 +52,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
IAccountService accountService,
IProviderService providerService,
IStoreManagementService storeManagementService,
IWinoAccountProfileService winoAccountProfileService,
IAuthenticationProvider authenticationProvider,
IPreferencesService preferencesService)
{
@@ -60,6 +61,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
AccountService = accountService;
ProviderService = providerService;
StoreManagementService = storeManagementService;
WinoAccountProfileService = winoAccountProfileService;
AuthenticationProvider = authenticationProvider;
PreferencesService = preferencesService;
}
@@ -75,7 +77,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
[RelayCommand]
public async Task PurchaseUnlimitedAccountAsync()
{
var purchaseResult = await StoreManagementService.PurchaseAsync(StoreProductType.UnlimitedAccounts);
var purchaseResult = await StoreManagementService.PurchaseAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS);
if (purchaseResult == StorePurchaseResult.Succeeded)
DialogService.InfoBarMessage(Translator.Info_PurchaseThankYouTitle, Translator.Info_PurchaseThankYouMessage, InfoBarMessageType.Success);
@@ -92,14 +94,12 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
public async Task ManageStorePurchasesAsync()
{
await ExecuteUIThread(async () =>
{
HasUnlimitedAccountProduct = await StoreManagementService.HasProductAsync(StoreProductType.UnlimitedAccounts);
var hasUnlimitedAccountProduct = await WinoAccountProfileService.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS).ConfigureAwait(false);
if (!HasUnlimitedAccountProduct)
IsAccountCreationBlocked = Accounts.Count >= FREE_ACCOUNT_COUNT;
else
IsAccountCreationBlocked = false;
await ExecuteUIThread(() =>
{
HasUnlimitedAccountProduct = hasUnlimitedAccountProduct;
IsAccountCreationBlocked = !hasUnlimitedAccountProduct && Accounts.Count >= FREE_ACCOUNT_COUNT;
});
}
@@ -0,0 +1,52 @@
#nullable enable
using CommunityToolkit.Mvvm.ComponentModel;
using System.Windows.Input;
using Wino.Core.Domain.Enums;
namespace Wino.Core.ViewModels.Data;
public partial class WinoAddOnItemViewModel : ObservableObject
{
public WinoAddOnProductType ProductType { get; }
public string NameKey => $"WinoAddOn_{ProductType}_Name";
public string DescriptionKey => $"WinoAddOn_{ProductType}_Description";
public string KeywordsKey => $"WinoAddOn_{ProductType}_Keywords";
public string IconGlyph => ProductType switch
{
WinoAddOnProductType.AI_PACK => "\uE945",
WinoAddOnProductType.UNLIMITED_ACCOUNTS => "\uE716",
_ => "\uE10F"
};
[ObservableProperty]
public partial bool IsPurchased { get; set; }
public ICommand? PurchaseCommand { get; set; }
public ICommand? ManageCommand { get; set; }
[ObservableProperty]
public partial bool IsPurchaseInProgress { get; set; }
[ObservableProperty]
public partial int UsageCount { get; set; }
[ObservableProperty]
public partial int UsageLimit { get; set; } = 1;
[ObservableProperty]
public partial double UsagePercentage { get; set; }
[ObservableProperty]
public partial string RenewalText { get; set; } = string.Empty;
[ObservableProperty]
public partial string UsageResetText { get; set; } = string.Empty;
public WinoAddOnItemViewModel(WinoAddOnProductType productType)
{
ProductType = productType;
}
}
@@ -1,17 +1,19 @@
#nullable enable
using System;
using System.Globalization;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Navigation;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Billing;
using Wino.Core.ViewModels.Data;
using Wino.Messaging.UI;
namespace Wino.Core.ViewModels;
@@ -20,27 +22,20 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
IRecipient<WinoAccountSignedInMessage>,
IRecipient<WinoAccountSignedOutMessage>
{
private const string AiPackProductId = "ai-pack-monthly";
private const string ManageAiPackUrl = "https://example.com/wino-ai-pack/manage";
private readonly IWinoAccountProfileService _profileService;
private readonly IWinoAddOnService _addOnService;
private readonly IMailDialogService _dialogService;
private readonly INativeAppService _nativeAppService;
public ObservableCollection<WinoAddOnItemViewModel> AddOns { get; } = [];
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsSignedOut))]
[NotifyPropertyChangedFor(nameof(CanShowBuyAiPack))]
public partial bool IsSignedIn { get; set; }
[ObservableProperty]
public partial string AccountDisplayName { get; set; } = string.Empty;
[ObservableProperty]
public partial string AccountInitials { get; set; } = string.Empty;
[ObservableProperty]
public partial string AccountEmail { get; set; } = string.Empty;
@@ -48,48 +43,18 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
public partial string AccountStatusText { get; set; } = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanShowAiUsage))]
[NotifyPropertyChangedFor(nameof(CanShowBuyAiPack))]
public partial bool HasAiPack { get; set; }
[ObservableProperty]
public partial string AiPackStateText { get; set; } = Translator.WinoAccount_Management_AiPackInactive;
[ObservableProperty]
public partial string AiUsageSummary { get; set; } = string.Empty;
[ObservableProperty]
public partial string AiBillingPeriodSummary { get; set; } = string.Empty;
[ObservableProperty]
public partial string AiPackRenewalText { get; set; } = string.Empty;
[ObservableProperty]
public partial int AiUsageCount { get; set; }
[ObservableProperty]
public partial int AiUsageLimit { get; set; } = 1;
[ObservableProperty]
public partial double AiUsagePercentage { get; set; }
[ObservableProperty]
public partial string AiUsageResetText { get; set; } = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanBuyAiPack))]
public partial bool IsAiPackCheckoutInProgress { get; set; }
[NotifyCanExecuteChangedFor(nameof(PurchaseAddOnCommand))]
public partial bool IsCheckoutInProgress { get; set; }
public bool IsSignedOut => !IsSignedIn;
public bool CanShowAiUsage => HasAiPack;
public bool CanShowBuyAiPack => IsSignedIn && !HasAiPack;
public bool CanBuyAiPack => !IsAiPackCheckoutInProgress;
public WinoAccountManagementPageViewModel(IWinoAccountProfileService profileService,
IWinoAddOnService addOnService,
IMailDialogService dialogService,
INativeAppService nativeAppService)
{
_profileService = profileService;
_addOnService = addOnService;
_dialogService = dialogService;
_nativeAppService = nativeAppService;
}
@@ -147,16 +112,15 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
InfoBarMessageType.Success);
}
[RelayCommand]
private async Task BuyAiPackAsync()
[RelayCommand(CanExecute = nameof(CanPurchaseAddOn))]
private async Task PurchaseAddOnAsync(WinoAddOnItemViewModel? addOn)
{
if (IsAiPackCheckoutInProgress)
if (addOn == null)
{
return;
}
var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false);
if (account == null)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Warning,
@@ -165,13 +129,17 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
return;
}
await ExecuteUIThread(() => IsAiPackCheckoutInProgress = true);
await ExecuteUIThread(() =>
{
IsCheckoutInProgress = true;
addOn.IsPurchaseInProgress = true;
});
try
{
var checkoutSession = await _profileService.CreateCheckoutSessionAsync(AiPackProductId).ConfigureAwait(false);
var checkoutSession = await _profileService.CreateCheckoutSessionAsync(addOn.ProductType).ConfigureAwait(false);
if (!checkoutSession.IsSuccess || string.IsNullOrWhiteSpace(checkoutSession.Result))
if (!checkoutSession.IsSuccess || string.IsNullOrWhiteSpace(checkoutSession.Result?.Url))
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
Translator.WinoAccount_Management_PurchaseStartFailed,
@@ -179,8 +147,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
return;
}
var isLaunched = await _nativeAppService.LaunchUriAsync(new Uri(checkoutSession.Result)).ConfigureAwait(false);
var isLaunched = await _nativeAppService.LaunchUriAsync(new Uri(checkoutSession.Result.Url)).ConfigureAwait(false);
if (!isLaunched)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
@@ -199,12 +166,49 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
}
finally
{
await ExecuteUIThread(() => IsAiPackCheckoutInProgress = false);
await ExecuteUIThread(() =>
{
IsCheckoutInProgress = false;
addOn.IsPurchaseInProgress = false;
});
}
}
private bool CanPurchaseAddOn(WinoAddOnItemViewModel? addOn)
=> addOn != null && !addOn.IsPurchased && !IsCheckoutInProgress;
[RelayCommand]
private async Task ManageAiPackAsync() => await _nativeAppService.LaunchUriAsync(new Uri(ManageAiPackUrl));
private async Task ManageAiPackAsync()
{
try
{
var portalSession = await _profileService.CreateCustomerPortalSessionAsync().ConfigureAwait(false);
if (!portalSession.IsSuccess || string.IsNullOrWhiteSpace(portalSession.Result?.Url))
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
Translator.WinoAccount_Management_PurchaseStartFailed,
InfoBarMessageType.Error);
return;
}
var isLaunched = await _nativeAppService.LaunchUriAsync(new Uri(portalSession.Result.Url)).ConfigureAwait(false);
if (!isLaunched)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
Translator.WinoAccount_Management_PurchaseStartFailed,
InfoBarMessageType.Error);
}
}
catch (OperationCanceledException)
{
}
catch (Exception)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
Translator.WinoAccount_Management_PurchaseStartFailed,
InfoBarMessageType.Error);
}
}
[RelayCommand]
private Task ExportSettingsAsync() => Task.CompletedTask;
@@ -232,7 +236,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
=> _ = LoadAsync();
public void Receive(WinoAccountSignedOutMessage message)
=> _ = ResetSignedOutStateAsync();
=> _ = LoadAsync();
private async Task LoadAsync()
{
@@ -241,44 +245,25 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
try
{
var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false);
if (account == null)
{
await ResetSignedOutStateAsync();
return;
}
var currentUserResponse = await _profileService.GetCurrentUserAsync().ConfigureAwait(false);
var aiStatusResponse = await _profileService.GetAiStatusAsync().ConfigureAwait(false);
var resolvedUser = currentUserResponse.IsSuccess && currentUserResponse.Result != null
? currentUserResponse.Result
: new AuthUserDto(account.Id, account.Email, account.AccountStatus, account.HasPassword, account.HasGoogleLogin, account.HasFacebookLogin);
var addOns = await _addOnService.GetAvailableAddOnsAsync().ConfigureAwait(false);
await ExecuteUIThread(() =>
{
IsSignedIn = true;
AccountEmail = resolvedUser.Email;
AccountDisplayName = ExtractDisplayName(resolvedUser.Email);
AccountInitials = ExtractInitials(resolvedUser.Email);
AccountStatusText = string.Format(Translator.WinoAccount_Management_StatusLabel, resolvedUser.AccountStatus);
IsSignedIn = account != null;
AccountEmail = account?.Email ?? string.Empty;
AccountStatusText = account == null
? string.Empty
: string.Format(Translator.WinoAccount_Management_StatusLabel, account.AccountStatus);
});
UpdateAiPackState(aiStatusResponse.IsSuccess ? aiStatusResponse.Result : null);
if (!currentUserResponse.IsSuccess || !aiStatusResponse.IsSuccess)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Warning,
Translator.WinoAccount_Management_LoadFailed,
InfoBarMessageType.Warning);
}
await UpdateAddOnsAsync(addOns).ConfigureAwait(false);
}
catch (Exception)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
Translator.WinoAccount_Management_LoadFailed,
InfoBarMessageType.Error);
await ResetSignedOutStateAsync();
await ResetStateAsync().ConfigureAwait(false);
}
finally
{
@@ -286,89 +271,54 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
}
}
private async Task ResetSignedOutStateAsync()
private async Task ResetStateAsync()
{
await ExecuteUIThread(() =>
{
IsSignedIn = false;
AccountDisplayName = string.Empty;
AccountInitials = string.Empty;
AccountEmail = string.Empty;
AccountStatusText = string.Empty;
HasAiPack = false;
AiPackStateText = Translator.WinoAccount_Management_AiPackInactive;
AiUsageSummary = string.Empty;
AiBillingPeriodSummary = string.Empty;
AiPackRenewalText = string.Empty;
AiUsageCount = 0;
AiUsageLimit = 1;
AiUsagePercentage = 0;
AiUsageResetText = string.Empty;
IsAiPackCheckoutInProgress = false;
IsCheckoutInProgress = false;
AddOns.Clear();
PurchaseAddOnCommand.NotifyCanExecuteChanged();
});
}
private void UpdateAiPackState(AiStatusResultDto? aiStatus)
private async Task UpdateAddOnsAsync(IReadOnlyList<WinoAddOnInfo> addOns)
{
var hasAiPack = aiStatus?.HasAiPack == true;
var usageText = Translator.WinoAccount_Management_AiPackUnknownUsage;
var billingText = string.Empty;
var renewalText = string.Empty;
var usageCount = 0;
var usageLimit = 1;
var usagePercentage = 0d;
var resetText = string.Empty;
var items = addOns.Select(CreateAddOnItem).ToList();
if (hasAiPack && aiStatus?.Used is int used && aiStatus.MonthlyLimit is int limit && aiStatus.Remaining is int remaining)
await ExecuteUIThread(() =>
{
usageText = string.Format(Translator.WinoAccount_Management_AiPackUsage, used, limit, remaining);
usageCount = used;
usageLimit = limit > 0 ? limit : 1;
usagePercentage = (double)used / usageLimit * 100;
AddOns.Clear();
foreach (var item in items)
{
AddOns.Add(item);
}
if (hasAiPack && aiStatus?.CurrentPeriodStartUtc is DateTimeOffset periodStart && aiStatus.CurrentPeriodEndUtc is DateTimeOffset periodEnd)
{
billingText = string.Format(Translator.WinoAccount_Management_AiPackBillingPeriod, periodStart.LocalDateTime, periodEnd.LocalDateTime);
renewalText = string.Format(Translator.WinoAccount_Management_AiPackRenews, periodEnd.LocalDateTime);
resetText = string.Format(Translator.WinoAccount_Management_AiPackResets, periodEnd.LocalDateTime);
}
_ = ExecuteUIThread(() =>
{
HasAiPack = hasAiPack;
AiPackStateText = hasAiPack
? Translator.WinoAccount_Management_AiPackActive
: Translator.WinoAccount_Management_AiPackInactive;
AiUsageSummary = hasAiPack ? usageText : string.Empty;
AiBillingPeriodSummary = hasAiPack ? billingText : string.Empty;
AiPackRenewalText = hasAiPack ? renewalText : string.Empty;
AiUsageCount = usageCount;
AiUsageLimit = usageLimit;
AiUsagePercentage = usagePercentage;
AiUsageResetText = hasAiPack ? resetText : string.Empty;
PurchaseAddOnCommand.NotifyCanExecuteChanged();
});
}
private static string ExtractDisplayName(string email)
private WinoAddOnItemViewModel CreateAddOnItem(WinoAddOnInfo addOn)
{
if (string.IsNullOrWhiteSpace(email))
return string.Empty;
var atIndex = email.IndexOf('@');
var localPart = atIndex > 0 ? email[..atIndex] : email;
if (localPart.Length == 0)
return string.Empty;
return char.ToUpper(localPart[0], CultureInfo.CurrentCulture) + localPart[1..];
}
private static string ExtractInitials(string email)
var item = new WinoAddOnItemViewModel(addOn.ProductType)
{
var displayName = ExtractDisplayName(email);
return displayName.Length > 0
? displayName[..1].ToUpper(CultureInfo.CurrentCulture)
: string.Empty;
IsPurchased = addOn.IsPurchased,
PurchaseCommand = PurchaseAddOnCommand,
ManageCommand = ManageAiPackCommand,
UsageCount = addOn.UsageCount ?? 0,
UsageLimit = addOn.UsageLimit is > 0 ? addOn.UsageLimit.Value : 1,
UsagePercentage = addOn.UsagePercentage
};
if (addOn.RenewalDateUtc is DateTimeOffset renewalDateUtc)
{
item.RenewalText = string.Format(Translator.WinoAccount_Management_AiPackRenews, renewalDateUtc.LocalDateTime);
item.UsageResetText = string.Format(Translator.WinoAccount_Management_AiPackResets, renewalDateUtc.LocalDateTime);
}
return item;
}
}
@@ -37,11 +37,12 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
IAccountService accountService,
IProviderService providerService,
IStoreManagementService storeManagementService,
IWinoAccountProfileService winoAccountProfileService,
IWinoLogger winoLogger,
ISpecialImapProviderConfigResolver specialImapProviderConfigResolver,
ICalDavClient calDavClient,
IAuthenticationProvider authenticationProvider,
IPreferencesService preferencesService) : base(dialogService, navigationService, accountService, providerService, storeManagementService, authenticationProvider, preferencesService)
IPreferencesService preferencesService) : base(dialogService, navigationService, accountService, providerService, storeManagementService, winoAccountProfileService, authenticationProvider, preferencesService)
{
MailDialogService = dialogService;
_winoLogger = winoLogger;
@@ -62,7 +62,7 @@ public sealed partial class WinoAccountLoginDialog : ContentDialog
if (!result.IsSuccess || result.Account == null)
{
ShowError(WinoAccountAuthErrorTranslator.Translate(result.ErrorCode));
ShowError(WinoAccountAuthErrorTranslator.Format(result.ErrorCode, result.ErrorMessage));
return;
}
@@ -64,7 +64,7 @@ public sealed partial class WinoAccountRegistrationDialog : ContentDialog
if (!result.IsSuccess || result.Account == null)
{
ShowError(WinoAccountAuthErrorTranslator.Translate(result.ErrorCode));
ShowError(WinoAccountAuthErrorTranslator.Format(result.ErrorCode, result.ErrorMessage));
return;
}
@@ -0,0 +1,30 @@
using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Wino.Core.Domain.Enums;
using Wino.Core.ViewModels.Data;
namespace Wino.Selectors;
public partial class WinoAddOnTemplateSelector : DataTemplateSelector
{
public DataTemplate? NotPurchasedTemplate { get; set; }
public DataTemplate? AiPackPurchasedTemplate { get; set; }
public DataTemplate? UnlimitedAccountsPurchasedTemplate { get; set; }
protected override DataTemplate SelectTemplateCore(object item, DependencyObject container)
{
if (item is not WinoAddOnItemViewModel addOn)
throw new ArgumentException(nameof(item));
if (!addOn.IsPurchased)
return NotPurchasedTemplate ?? throw new ArgumentException(nameof(NotPurchasedTemplate));
return addOn.ProductType switch
{
WinoAddOnProductType.AI_PACK => AiPackPurchasedTemplate ?? throw new ArgumentException(nameof(AiPackPurchasedTemplate)),
WinoAddOnProductType.UNLIMITED_ACCOUNTS => UnlimitedAccountsPurchasedTemplate ?? throw new ArgumentException(nameof(UnlimitedAccountsPurchasedTemplate)),
_ => NotPurchasedTemplate ?? throw new ArgumentException(nameof(NotPurchasedTemplate))
};
}
}
@@ -3,8 +3,8 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using Windows.Services.Store;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Store;
using WinoStorePurchaseResult = Wino.Core.Domain.Enums.StorePurchaseResult;
using WinoAddOnProductType = Wino.Core.Domain.Enums.WinoAddOnProductType;
namespace Wino.Mail.WinUI.Services;
@@ -12,14 +12,14 @@ public class StoreManagementService : IStoreManagementService
{
private StoreContext CurrentContext { get; }
private readonly Dictionary<StoreProductType, string> productIds = new Dictionary<StoreProductType, string>()
private readonly Dictionary<WinoAddOnProductType, string> productIds = new Dictionary<WinoAddOnProductType, string>()
{
{ StoreProductType.UnlimitedAccounts, "UnlimitedAccounts" }
{ WinoAddOnProductType.UNLIMITED_ACCOUNTS, "UnlimitedAccounts" }
};
private readonly Dictionary<StoreProductType, string> skuIds = new Dictionary<StoreProductType, string>()
private readonly Dictionary<WinoAddOnProductType, string> skuIds = new Dictionary<WinoAddOnProductType, string>()
{
{ StoreProductType.UnlimitedAccounts, "9P02MXZ42GSM" }
{ WinoAddOnProductType.UNLIMITED_ACCOUNTS, "9P02MXZ42GSM" }
};
public StoreManagementService()
@@ -27,9 +27,11 @@ public class StoreManagementService : IStoreManagementService
CurrentContext = StoreContext.GetDefault();
}
public async Task<bool> HasProductAsync(StoreProductType productType)
public async Task<bool> HasProductAsync(WinoAddOnProductType productType)
{
var productKey = productIds[productType];
if (!productIds.TryGetValue(productType, out var productKey))
return false;
var appLicense = await CurrentContext.GetAppLicenseAsync();
if (appLicense == null)
@@ -49,14 +51,15 @@ public class StoreManagementService : IStoreManagementService
return false;
}
public async Task<WinoStorePurchaseResult> PurchaseAsync(StoreProductType productType)
public async Task<WinoStorePurchaseResult> PurchaseAsync(WinoAddOnProductType productType)
{
if (!skuIds.TryGetValue(productType, out var productKey))
return WinoStorePurchaseResult.NotPurchased;
if (await HasProductAsync(productType))
return WinoStorePurchaseResult.AlreadyPurchased;
else
{
var productKey = skuIds[productType];
var result = await CurrentContext.RequestPurchaseAsync(productKey);
switch (result.Status)
@@ -30,4 +30,34 @@ public static class WinoAccountAuthErrorTranslator
_ => errorCode
};
}
public static string Format(string? errorCode, string? errorMessage)
{
var translatedCode = Translate(errorCode);
var hasCode = !string.IsNullOrWhiteSpace(errorCode);
var hasMessage = !string.IsNullOrWhiteSpace(errorMessage);
if (!hasCode && !hasMessage)
{
return Translator.GeneralTitle_Error;
}
var formattedCode = translatedCode;
if (hasCode && !string.Equals(translatedCode, errorCode, System.StringComparison.Ordinal))
{
formattedCode = $"{translatedCode} ({errorCode})";
}
if (!hasMessage || string.Equals(errorMessage, translatedCode, System.StringComparison.OrdinalIgnoreCase) || string.Equals(errorMessage, errorCode, System.StringComparison.OrdinalIgnoreCase))
{
return formattedCode;
}
if (string.IsNullOrWhiteSpace(formattedCode))
{
return errorMessage!;
}
return $"{formattedCode}{System.Environment.NewLine}{errorMessage}";
}
}
@@ -7,306 +7,59 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:selectors="using:Wino.Selectors"
xmlns:viewModelData="using:Wino.Core.ViewModels.Data"
x:Name="root"
Title="{x:Bind domain:Translator.WinoAccount_SettingsSection_Title}"
mc:Ignorable="d">
<ScrollViewer>
<StackPanel Spacing="{StaticResource SettingsCardSpacing}">
<!-- Busy indicator -->
<StackPanel
x:Name="BusyPanel"
HorizontalAlignment="Center"
x:Load="{x:Bind ViewModel.IsBusy, Mode=OneWay}"
Spacing="8">
<ProgressRing
Width="32"
Height="32"
IsActive="True" />
<TextBlock HorizontalAlignment="Center" Text="{x:Bind domain:Translator.Busy}" />
</StackPanel>
<!-- ══════════════════════════════════════ -->
<!-- SIGNED-OUT VIEW -->
<!-- ══════════════════════════════════════ -->
<StackPanel
x:Name="SignedOutPanel"
x:Load="{x:Bind ViewModel.IsSignedOut, Mode=OneWay}"
Spacing="{StaticResource SettingsCardSpacing}">
<StackPanel
Padding="0,40,0,40"
HorizontalAlignment="Center"
Spacing="16">
<!-- App logo -->
<Image
Width="64"
Height="64"
HorizontalAlignment="Center"
Source="ms-appx:///Assets/AppEntries/MailAssets/Square150x150Logo.scale-100.png" />
<!-- Title -->
<Page.Resources>
<DataTemplate x:Key="AddOnNotPurchasedTemplate" x:DataType="viewModelData:WinoAddOnItemViewModel">
<controls:SettingsCard ActionIcon="Add" IsActionIconVisible="True">
<controls:SettingsCard.HeaderIcon>
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="{x:Bind IconGlyph}" />
</controls:SettingsCard.HeaderIcon>
<controls:SettingsCard.Header>
<StackPanel Spacing="8">
<TextBlock FontWeight="SemiBold" Text="{x:Bind domain:Translator.GetTranslatedString(NameKey), Mode=OneWay}" />
<TextBlock
HorizontalAlignment="Center"
FontSize="20"
FontWeight="SemiBold"
Text="{x:Bind domain:Translator.WinoAccount_Management_SignedOutTitle}" />
<!-- Description -->
<TextBlock
MaxWidth="360"
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource BodyTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_SignedOutDescription}"
TextAlignment="Center"
Text="{x:Bind domain:Translator.GetTranslatedString(DescriptionKey), Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<!-- Action buttons -->
<StackPanel
HorizontalAlignment="Center"
Orientation="Horizontal"
Spacing="12">
<Button
Command="{x:Bind ViewModel.SignInCommand}"
Content="{x:Bind domain:Translator.Buttons_SignIn}"
Style="{StaticResource AccentButtonStyle}" />
<Button Command="{x:Bind ViewModel.RegisterCommand}" Content="{x:Bind domain:Translator.Buttons_CreateAccount}" />
</StackPanel>
<TextBlock
Margin="0,16,0,4"
HorizontalAlignment="Center"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackSectionHeader}" />
<controls:SettingsCard
MaxWidth="520"
Description="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoDescription}">
<controls:SettingsCard.HeaderIcon>
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE945;" />
</controls:SettingsCard.HeaderIcon>
<controls:SettingsCard.Header>
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock FontWeight="SemiBold" Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoTitle}" />
<Border
Padding="8,2"
Background="{ThemeResource SystemAccentColor}"
CornerRadius="4">
<TextBlock
FontSize="10"
FontWeight="Bold"
Foreground="White"
Text="PRO" />
</Border>
</StackPanel>
<TextBlock
MaxWidth="400"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoDescription}"
Text="{x:Bind domain:Translator.GetTranslatedString(KeywordsKey), Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<StackPanel Orientation="Horizontal" Spacing="16">
<StackPanel Orientation="Horizontal" Spacing="4">
<FontIcon
FontFamily="{StaticResource SymbolThemeFontFamily}"
FontSize="12"
Glyph="&#xE8C1;" />
<TextBlock Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackFeatureTranslate}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4">
<FontIcon
FontFamily="{StaticResource SymbolThemeFontFamily}"
FontSize="12"
Glyph="&#xE70F;" />
<TextBlock Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackFeatureRewrite}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4">
<FontIcon
FontFamily="{StaticResource SymbolThemeFontFamily}"
FontSize="12"
Glyph="&#xE8FD;" />
<TextBlock Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackFeatureSummarize}" />
</StackPanel>
</StackPanel>
<Border
Padding="12,8"
Background="{ThemeResource SystemFillColorCautionBackgroundBrush}"
CornerRadius="8">
<TextBlock
Foreground="{ThemeResource SystemFillColorCautionBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_PurchaseRequiresSignIn}"
TextWrapping="WrapWholeWords" />
</Border>
<StackPanel
Margin="0,4,0,0"
Orientation="Horizontal"
Spacing="12">
<Button
Command="{x:Bind ViewModel.BuyAiPackCommand}"
Command="{x:Bind PurchaseCommand, Mode=OneWay}"
CommandParameter="{x:Bind}"
Content="{x:Bind domain:Translator.Buttons_Purchase}"
IsEnabled="{x:Bind ViewModel.CanBuyAiPack, Mode=OneWay}"
Style="{StaticResource AccentButtonStyle}" />
<ProgressRing
Width="18"
Height="18"
IsActive="True"
Visibility="{x:Bind ViewModel.IsAiPackCheckoutInProgress, Mode=OneWay}" />
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}">
<Run Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoPrice}" />
<Run Text=" · " />
<Run Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoRequests}" />
</TextBlock>
IsActive="{x:Bind IsPurchaseInProgress, Mode=OneWay}"
Visibility="{x:Bind IsPurchaseInProgress, Mode=OneWay}" />
</StackPanel>
</StackPanel>
</controls:SettingsCard.Header>
</controls:SettingsCard>
</StackPanel>
</StackPanel>
</DataTemplate>
<!-- ══════════════════════════════════════ -->
<!-- SIGNED-IN VIEW -->
<!-- ══════════════════════════════════════ -->
<StackPanel
x:Name="SignedInPanel"
x:Load="{x:Bind ViewModel.IsSignedIn, Mode=OneWay}"
Spacing="{StaticResource SettingsCardSpacing}">
<!-- ─── Profile section ─── -->
<TextBlock
Margin="0,0,0,4"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_ProfileSectionHeader}" />
<controls:SettingsCard>
<controls:SettingsCard.Header>
<StackPanel Orientation="Horizontal" Spacing="12">
<PersonPicture
Width="40"
Height="40"
Initials="{x:Bind ViewModel.AccountInitials, Mode=OneWay}" />
<StackPanel VerticalAlignment="Center" Spacing="2">
<TextBlock FontWeight="SemiBold" Text="{x:Bind ViewModel.AccountDisplayName, Mode=OneWay}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.AccountEmail, Mode=OneWay}" />
</StackPanel>
</StackPanel>
</controls:SettingsCard.Header>
<Border
Padding="12,4"
Background="{ThemeResource SystemFillColorSuccessBackgroundBrush}"
CornerRadius="12">
<TextBlock
Foreground="{ThemeResource SystemFillColorSuccessBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.AccountStatusText, Mode=OneWay}" />
</Border>
</controls:SettingsCard>
<!-- ─── AI Pack section ─── -->
<TextBlock
Margin="0,12,0,4"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackSectionHeader}" />
<!-- AI Pack promo (no AI Pack) -->
<controls:SettingsCard
x:Name="AiPackPromoCard"
x:Load="{x:Bind ViewModel.CanShowBuyAiPack, Mode=OneWay}"
Description="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoDescription}">
<controls:SettingsCard.HeaderIcon>
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE945;" />
</controls:SettingsCard.HeaderIcon>
<controls:SettingsCard.Header>
<StackPanel Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock FontWeight="SemiBold" Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoTitle}" />
<Border
Padding="8,2"
Background="{ThemeResource SystemAccentColor}"
CornerRadius="4">
<TextBlock
FontSize="10"
FontWeight="Bold"
Foreground="White"
Text="PRO" />
</Border>
</StackPanel>
<TextBlock
MaxWidth="400"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoDescription}"
TextWrapping="WrapWholeWords" />
<StackPanel Orientation="Horizontal" Spacing="16">
<StackPanel Orientation="Horizontal" Spacing="4">
<FontIcon
FontFamily="{StaticResource SymbolThemeFontFamily}"
FontSize="12"
Glyph="&#xE8C1;" />
<TextBlock Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackFeatureTranslate}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4">
<FontIcon
FontFamily="{StaticResource SymbolThemeFontFamily}"
FontSize="12"
Glyph="&#xE70F;" />
<TextBlock Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackFeatureRewrite}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="4">
<FontIcon
FontFamily="{StaticResource SymbolThemeFontFamily}"
FontSize="12"
Glyph="&#xE8FD;" />
<TextBlock Style="{StaticResource CaptionTextBlockStyle}" Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackFeatureSummarize}" />
</StackPanel>
</StackPanel>
<StackPanel
Margin="0,4,0,0"
Orientation="Horizontal"
Spacing="12">
<Button
Command="{x:Bind ViewModel.BuyAiPackCommand}"
Content="{x:Bind domain:Translator.Buttons_Purchase}"
IsEnabled="{x:Bind ViewModel.CanBuyAiPack, Mode=OneWay}"
Style="{StaticResource AccentButtonStyle}" />
<ProgressRing
Width="18"
Height="18"
IsActive="True"
Visibility="{x:Bind ViewModel.IsAiPackCheckoutInProgress, Mode=OneWay}" />
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}">
<Run Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoPrice}" />
<Run Text=" · " />
<Run Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoRequests}" />
</TextBlock>
</StackPanel>
</StackPanel>
</controls:SettingsCard.Header>
</controls:SettingsCard>
<!-- AI Pack active: subscription info -->
<controls:SettingsExpander
x:Name="AiPackActiveCard"
x:Load="{x:Bind ViewModel.HasAiPack, Mode=OneWay}"
IsExpanded="True">
<DataTemplate x:Key="AiPackPurchasedTemplate" x:DataType="viewModelData:WinoAddOnItemViewModel">
<controls:SettingsExpander IsExpanded="True">
<controls:SettingsExpander.HeaderIcon>
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE945;" />
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="{x:Bind IconGlyph}" />
</controls:SettingsExpander.HeaderIcon>
<controls:SettingsExpander.Header>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock FontWeight="SemiBold" Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackCardTitle}" />
<TextBlock FontWeight="SemiBold" Text="{x:Bind domain:Translator.GetTranslatedString(NameKey), Mode=OneWay}" />
<Border
Padding="8,2"
Background="{ThemeResource SystemAccentColor}"
@@ -332,35 +85,32 @@
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.AiPackRenewalText, Mode=OneWay}" />
Text="{x:Bind RenewalText, Mode=OneWay}" />
</StackPanel>
</controls:SettingsExpander.Description>
<HyperlinkButton Command="{x:Bind ViewModel.ManageAiPackCommand}" Content="{x:Bind domain:Translator.Buttons_Manage}" />
<HyperlinkButton Command="{x:Bind ManageCommand, Mode=OneWay}" Content="{x:Bind domain:Translator.Buttons_Manage}" />
<controls:SettingsExpander.Items>
<!-- AI Pack active: usage bar -->
<controls:SettingsCard x:Name="AiPackUsageCard" HorizontalContentAlignment="Stretch">
<controls:SettingsCard HorizontalContentAlignment="Stretch">
<controls:SettingsCard.Header>
<StackPanel MinWidth="400" Spacing="8">
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock
FontSize="24"
FontWeight="Bold"
Text="{x:Bind ViewModel.AiUsageCount, Mode=OneWay}" />
Text="{x:Bind UsageCount, Mode=OneWay}" />
<TextBlock
Margin="0,0,0,2"
VerticalAlignment="Bottom"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}">
<Run Text="/ " />
<Run Text="{x:Bind ViewModel.AiUsageLimit, Mode=OneWay}" />
<Run Text="{x:Bind UsageLimit, Mode=OneWay}" />
</TextBlock>
</StackPanel>
<ProgressBar
Height="8"
Maximum="100"
Value="{x:Bind ViewModel.AiUsagePercentage, Mode=OneWay}" />
Value="{x:Bind UsagePercentage, Mode=OneWay}" />
<Grid>
<TextBlock
HorizontalAlignment="Left"
@@ -371,37 +121,186 @@
HorizontalAlignment="Right"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.AiUsageResetText, Mode=OneWay}" />
Text="{x:Bind UsageResetText, Mode=OneWay}" />
</Grid>
</StackPanel>
</controls:SettingsCard.Header>
</controls:SettingsCard>
</controls:SettingsExpander.Items>
</controls:SettingsExpander>
</DataTemplate>
<DataTemplate x:Key="UnlimitedAccountsPurchasedTemplate" x:DataType="viewModelData:WinoAddOnItemViewModel">
<controls:SettingsCard>
<controls:SettingsCard.HeaderIcon>
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="{x:Bind IconGlyph}" />
</controls:SettingsCard.HeaderIcon>
<controls:SettingsCard.Header>
<StackPanel Spacing="8">
<TextBlock FontWeight="SemiBold" Text="{x:Bind domain:Translator.GetTranslatedString(NameKey), Mode=OneWay}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource BodyTextBlockStyle}"
Text="{x:Bind domain:Translator.GetTranslatedString(DescriptionKey), Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.GetTranslatedString(KeywordsKey), Mode=OneWay}"
TextWrapping="WrapWholeWords" />
<Border
Padding="12,4"
HorizontalAlignment="Left"
Background="{ThemeResource SystemFillColorSuccessBackgroundBrush}"
CornerRadius="12">
<TextBlock
Foreground="{ThemeResource SystemFillColorSuccessBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.Purchased}" />
</Border>
</StackPanel>
</controls:SettingsCard.Header>
</controls:SettingsCard>
</DataTemplate>
<selectors:WinoAddOnTemplateSelector
x:Key="WinoAddOnTemplateSelector"
AiPackPurchasedTemplate="{StaticResource AiPackPurchasedTemplate}"
NotPurchasedTemplate="{StaticResource AddOnNotPurchasedTemplate}"
UnlimitedAccountsPurchasedTemplate="{StaticResource UnlimitedAccountsPurchasedTemplate}" />
</Page.Resources>
<ScrollViewer>
<StackPanel Spacing="{StaticResource SettingsCardSpacing}">
<StackPanel
x:Name="BusyPanel"
HorizontalAlignment="Center"
x:Load="{x:Bind ViewModel.IsBusy, Mode=OneWay}"
Spacing="8">
<ProgressRing
Width="32"
Height="32"
IsActive="True" />
<TextBlock HorizontalAlignment="Center" Text="{x:Bind domain:Translator.Busy}" />
</StackPanel>
<StackPanel
x:Name="SignedOutPanel"
HorizontalAlignment="Stretch"
x:Load="{x:Bind ViewModel.IsSignedOut, Mode=OneWay}"
Spacing="{StaticResource SettingsCardSpacing}">
<StackPanel
Padding="0,40,0,40"
HorizontalAlignment="Stretch"
Spacing="16">
<Image
Width="64"
Height="64"
HorizontalAlignment="Center"
Source="ms-appx:///Assets/AppEntries/MailAssets/Square150x150Logo.scale-100.png" />
<TextBlock
HorizontalAlignment="Center"
FontSize="20"
FontWeight="SemiBold"
Text="{x:Bind domain:Translator.WinoAccount_Management_SignedOutTitle}" />
<TextBlock
MaxWidth="360"
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource BodyTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_SignedOutDescription}"
TextAlignment="Center"
TextWrapping="WrapWholeWords" />
<StackPanel
HorizontalAlignment="Center"
Orientation="Horizontal"
Spacing="12">
<Button
Command="{x:Bind ViewModel.SignInCommand}"
Content="{x:Bind domain:Translator.Buttons_SignIn}"
Style="{StaticResource AccentButtonStyle}" />
<Button Command="{x:Bind ViewModel.RegisterCommand}" Content="{x:Bind domain:Translator.Buttons_CreateAccount}" />
</StackPanel>
<TextBlock
Margin="0,16,0,4"
HorizontalAlignment="Center"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_AddOnsSectionHeader}" />
<ListView
x:Name="SignedOutAddOnsList"
ItemContainerStyle="{StaticResource StretchedItemContainerStyle}"
ItemTemplateSelector="{StaticResource WinoAddOnTemplateSelector}"
ItemsSource="{x:Bind ViewModel.AddOns, Mode=OneWay}"
SelectionMode="None" />
</StackPanel>
</StackPanel>
<StackPanel
x:Name="SignedInPanel"
x:Load="{x:Bind ViewModel.IsSignedIn, Mode=OneWay}"
Spacing="{StaticResource SettingsCardSpacing}">
<TextBlock
Margin="0,0,0,4"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_ProfileSectionHeader}" />
<controls:SettingsCard Header="{x:Bind ViewModel.AccountEmail, Mode=OneWay}">
<controls:SettingsCard.HeaderIcon>
<PathIcon
HorizontalAlignment="Center"
VerticalAlignment="Center"
Data="F1 M 3.75 5 L 3.75 4.902344 C 3.75 4.225262 3.885091 3.588867 4.155273 2.993164 C 4.425456 2.397461 4.790039 1.878256 5.249023 1.435547 C 5.708008 0.99284 6.238606 0.642904 6.84082 0.385742 C 7.443034 0.128582 8.079427 0 8.75 0 C 9.440104 0 10.089518 0.130209 10.698242 0.390625 C 11.306966 0.651043 11.837564 1.007488 12.290039 1.459961 C 12.742513 1.912436 13.098958 2.443035 13.359375 3.051758 C 13.619791 3.660482 13.75 4.309896 13.75 5 C 13.75 5.690104 13.619791 6.339519 13.359375 6.948242 C 13.098958 7.556967 12.742513 8.087565 12.290039 8.540039 C 11.837564 8.992514 11.306966 9.348959 10.698242 9.609375 C 10.089518 9.869792 9.440104 10 8.75 10 C 8.059896 10 7.410481 9.869792 6.801758 9.609375 C 6.193034 9.348959 5.662435 8.992514 5.209961 8.540039 C 4.757487 8.087565 4.401042 7.556967 4.140625 6.948242 C 3.880208 6.339519 3.75 5.690104 3.75 5 Z M 5 5 L 5 5.078125 C 5 5.585938 5.100911 6.062826 5.302734 6.508789 C 5.504557 6.954753 5.777995 7.34375 6.123047 7.675781 C 6.468099 8.007812 6.866862 8.269857 7.319336 8.461914 C 7.77181 8.653972 8.248697 8.75 8.75 8.75 C 9.270833 8.75 9.759114 8.652344 10.214844 8.457031 C 10.670572 8.261719 11.067708 7.994792 11.40625 7.65625 C 11.744791 7.317709 12.011719 6.920573 12.207031 6.464844 C 12.402344 6.009115 12.5 5.520834 12.5 5 C 12.5 4.479167 12.402344 3.990887 12.207031 3.535156 C 12.011719 3.079428 11.744791 2.682293 11.40625 2.34375 C 11.067708 2.005209 10.670572 1.738281 10.214844 1.542969 C 9.759114 1.347656 9.270833 1.25 8.75 1.25 C 8.229166 1.25 7.740885 1.347656 7.285156 1.542969 C 6.829427 1.738281 6.432292 2.005209 6.09375 2.34375 C 5.755208 2.682293 5.488281 3.079428 5.292969 3.535156 C 5.097656 3.990887 5 4.479167 5 5 Z M 20 14.375 L 20 18.125 C 20 18.385416 19.951172 18.629557 19.853516 18.857422 C 19.755859 19.085287 19.622395 19.283854 19.453125 19.453125 C 19.283854 19.622396 19.085285 19.755859 18.857422 19.853516 C 18.629557 19.951172 18.385416 20 18.125 20 L 11.875 20 C 11.614583 20 11.370442 19.951172 11.142578 19.853516 C 10.914713 19.755859 10.716146 19.622396 10.546875 19.453125 C 10.377604 19.283854 10.244141 19.085287 10.146484 18.857422 C 10.048828 18.629557 10 18.385416 10 18.125 L 10 14.375 C 10 14.114584 10.048828 13.870443 10.146484 13.642578 C 10.244141 13.414714 10.377604 13.216146 10.546875 13.046875 C 10.716146 12.877604 10.914713 12.744141 11.142578 12.646484 C 11.370442 12.548828 11.614583 12.5 11.875 12.5 L 12.5 12.5 L 12.5 11.25 C 12.5 11.080729 12.532552 10.921225 12.597656 10.771484 C 12.66276 10.621745 12.753906 10.488281 12.871094 10.371094 C 13.118488 10.123698 13.411457 10 13.75 10 L 16.25 10 C 16.41927 10 16.578775 10.032553 16.728516 10.097656 C 16.878254 10.162761 17.011719 10.253906 17.128906 10.371094 C 17.376301 10.61849 17.5 10.911459 17.5 11.25 L 17.5 12.5 L 18.125 12.5 C 18.385416 12.5 18.629557 12.548828 18.857422 12.646484 C 19.085285 12.744141 19.283854 12.877604 19.453125 13.046875 C 19.622395 13.216146 19.755859 13.414714 19.853516 13.642578 C 19.951172 13.870443 20 14.114584 20 14.375 Z M 11.25 11.25 C 10.800781 11.25 10.382486 11.360678 9.995117 11.582031 C 9.607747 11.803386 9.303385 12.109375 9.082031 12.5 L 2.5 12.5 C 2.324219 12.5 2.159831 12.532553 2.006836 12.597656 C 1.853841 12.662761 1.722005 12.750651 1.611328 12.861328 C 1.500651 12.972006 1.41276 13.103842 1.347656 13.256836 C 1.282552 13.409831 1.25 13.574219 1.25 13.75 C 1.25 14.420573 1.360677 15.008139 1.582031 15.512695 C 1.803385 16.017252 2.102865 16.455078 2.480469 16.826172 C 2.858073 17.197266 3.297526 17.50651 3.798828 17.753906 C 4.30013 18.001303 4.827474 18.198242 5.380859 18.344727 C 5.934244 18.491211 6.50065 18.595377 7.080078 18.657227 C 7.659505 18.719076 8.216146 18.75 8.75 18.75 C 8.75 19.205729 8.860677 19.622396 9.082031 20 L 8.75 20 C 8.190104 20 7.618814 19.973959 7.036133 19.921875 C 6.45345 19.869791 5.878906 19.775391 5.3125 19.638672 C 4.746094 19.501953 4.197591 19.319662 3.666992 19.091797 C 3.136393 18.863932 2.646484 18.574219 2.197266 18.222656 C 1.474609 17.66276 0.927734 17.005209 0.556641 16.25 C 0.185547 15.494792 0 14.661458 0 13.75 C 0 13.404948 0.065104 13.081055 0.195312 12.77832 C 0.325521 12.475586 0.504557 12.210287 0.732422 11.982422 C 0.960286 11.754558 1.225586 11.575521 1.52832 11.445312 C 1.831055 11.315104 2.154948 11.25 2.5 11.25 Z M 16.25 11.25 L 13.75 11.25 L 13.75 12.5 L 16.25 12.5 Z " />
</controls:SettingsCard.HeaderIcon>
<Border
Padding="12,4"
Background="{ThemeResource SystemFillColorSuccessBackgroundBrush}"
CornerRadius="12">
<TextBlock
Foreground="{ThemeResource SystemFillColorSuccessBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.AccountStatusText, Mode=OneWay}" />
</Border>
</controls:SettingsCard>
<TextBlock
Margin="0,12,0,4"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_AddOnsSectionHeader}" />
<ListView
x:Name="SignedInAddOnsList"
ItemContainerStyle="{StaticResource StretchedItemContainerStyle}"
ItemTemplateSelector="{StaticResource WinoAddOnTemplateSelector}"
ItemsSource="{x:Bind ViewModel.AddOns, Mode=OneWay}"
SelectionMode="None" />
<!-- ─── Data section ─── -->
<TextBlock
Margin="0,12,0,4"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_DataSectionHeader}" />
<controls:SettingsCard Description="{x:Bind domain:Translator.WinoAccount_Management_ImportSettingsDescription}" Header="{x:Bind domain:Translator.WinoAccount_Management_ImportSettingsTitle}">
<controls:SettingsCard Description="{x:Bind domain:Translator.WinoAccount_Management_SyncPreferencesDescription}" Header="{x:Bind domain:Translator.WinoAccount_Management_SyncPreferencesTitle}">
<controls:SettingsCard.HeaderIcon>
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE896;" />
</controls:SettingsCard.HeaderIcon>
<Button Command="{x:Bind ViewModel.ImportSettingsCommand}" Content="{x:Bind domain:Translator.Buttons_Browse}" />
</controls:SettingsCard>
<controls:SettingsCard Description="{x:Bind domain:Translator.WinoAccount_Management_ExportSettingsDescription}" Header="{x:Bind domain:Translator.WinoAccount_Management_ExportSettingsTitle}">
<controls:SettingsCard.HeaderIcon>
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE898;" />
<PathIcon
HorizontalAlignment="Center"
VerticalAlignment="Center"
Data="F1 M 18.330078 3.330078 L 18.330078 7.5 C 18.330078 7.727865 18.248697 7.923178 18.085938 8.085938 C 17.923176 8.248698 17.727863 8.330078 17.5 8.330078 L 13.330078 8.330078 C 13.102213 8.330078 12.9069 8.248698 12.744141 8.085938 C 12.58138 7.923178 12.5 7.727865 12.5 7.5 C 12.5 7.272136 12.58138 7.076823 12.744141 6.914062 C 12.9069 6.751303 13.102213 6.669923 13.330078 6.669922 L 15.78125 6.669922 C 15.481771 6.149089 15.120442 5.681967 14.697266 5.268555 C 14.274088 4.855145 13.808593 4.505209 13.300781 4.21875 C 12.792968 3.932293 12.25423 3.712566 11.68457 3.55957 C 11.114908 3.406576 10.530599 3.330078 9.931641 3.330078 C 9.384766 3.330078 8.846028 3.401693 8.31543 3.544922 C 7.784831 3.688152 7.280273 3.891602 6.801758 4.155273 C 6.323242 4.418945 5.878906 4.737956 5.46875 5.112305 C 5.058594 5.486654 4.703776 5.901693 4.404297 6.357422 C 4.254557 6.585287 4.127604 6.816407 4.023438 7.050781 C 3.919271 7.285157 3.815104 7.526043 3.710938 7.773438 C 3.639323 7.942709 3.538411 8.0778 3.408203 8.178711 C 3.277995 8.279623 3.11849 8.330078 2.929688 8.330078 C 2.701823 8.330078 2.504883 8.250326 2.338867 8.09082 C 2.172852 7.931315 2.089844 7.734375 2.089844 7.5 C 2.089844 7.415365 2.10612 7.32422 2.138672 7.226562 C 2.41862 6.412761 2.81901 5.66569 3.339844 4.985352 C 3.860677 4.305014 4.464518 3.719076 5.151367 3.227539 C 5.838216 2.736004 6.588541 2.353516 7.402344 2.080078 C 8.216146 1.806641 9.052734 1.669922 9.912109 1.669922 C 10.576172 1.669922 11.227213 1.743164 11.865234 1.889648 C 12.503255 2.036133 13.111979 2.250977 13.691406 2.53418 C 14.270832 2.817383 14.811197 3.167318 15.3125 3.583984 C 15.813802 4.000652 16.266275 4.475912 16.669922 5.009766 L 16.669922 3.330078 C 16.669922 3.102215 16.751301 2.906902 16.914062 2.744141 C 17.076822 2.581381 17.272135 2.5 17.5 2.5 C 17.727863 2.5 17.923176 2.581381 18.085938 2.744141 C 18.248697 2.906902 18.330078 3.102215 18.330078 3.330078 Z M 17.861328 12.5 C 17.861328 12.584636 17.845051 12.675781 17.8125 12.773438 C 17.532551 13.58724 17.13216 14.334311 16.611328 15.014648 C 16.090494 15.694987 15.486653 16.280924 14.799805 16.772461 C 14.112955 17.263998 13.362629 17.646484 12.548828 17.919922 C 11.735025 18.193359 10.898438 18.330078 10.039062 18.330078 C 9.375 18.330078 8.728841 18.258463 8.100586 18.115234 C 7.472331 17.972006 6.871745 17.762045 6.298828 17.485352 C 5.725911 17.208658 5.188802 16.865234 4.6875 16.455078 C 4.186198 16.044922 3.733724 15.576172 3.330078 15.048828 L 3.330078 16.669922 C 3.330078 16.897787 3.248698 17.0931 3.085938 17.255859 C 2.923177 17.418619 2.727865 17.5 2.5 17.5 C 2.272135 17.5 2.076823 17.418619 1.914062 17.255859 C 1.751302 17.0931 1.669922 16.897787 1.669922 16.669922 L 1.669922 12.5 C 1.669922 12.272136 1.751302 12.076823 1.914062 11.914062 C 2.076823 11.751303 2.272135 11.669922 2.5 11.669922 L 6.669922 11.669922 C 6.897786 11.669922 7.093099 11.751303 7.255859 11.914062 C 7.418619 12.076823 7.5 12.272136 7.5 12.5 C 7.5 12.727865 7.418619 12.923178 7.255859 13.085938 C 7.093099 13.248698 6.897786 13.330078 6.669922 13.330078 L 4.179688 13.330078 C 4.479167 13.850912 4.840495 14.318034 5.263672 14.731445 C 5.686849 15.144857 6.150716 15.494792 6.655273 15.78125 C 7.15983 16.067709 7.696939 16.287436 8.266602 16.44043 C 8.836263 16.593424 9.420572 16.669922 10.019531 16.669922 C 10.566406 16.669922 11.106771 16.598307 11.640625 16.455078 C 12.174479 16.31185 12.680664 16.108398 13.15918 15.844727 C 13.637695 15.581055 14.080403 15.262045 14.487305 14.887695 C 14.894205 14.513347 15.247396 14.095053 15.546875 13.632812 C 15.696614 13.404948 15.821939 13.173828 15.922852 12.939453 C 16.023762 12.705078 16.129557 12.464193 16.240234 12.216797 C 16.311848 12.047526 16.414387 11.912436 16.547852 11.811523 C 16.681314 11.710612 16.842447 11.660156 17.03125 11.660156 C 17.148438 11.660156 17.257486 11.682943 17.358398 11.728516 C 17.459309 11.774089 17.547199 11.834311 17.62207 11.90918 C 17.696939 11.98405 17.755533 12.07194 17.797852 12.172852 C 17.840168 12.273764 17.861328 12.382812 17.861328 12.5 Z " />
</controls:SettingsCard.HeaderIcon>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Command="{x:Bind ViewModel.ExportSettingsCommand}" Content="{x:Bind domain:Translator.Buttons_Export}" />
<Button Command="{x:Bind ViewModel.ImportSettingsCommand}" Content="{x:Bind domain:Translator.Buttons_Import}" />
</StackPanel>
</controls:SettingsCard>
<!-- ─── Account actions section ─── -->
<TextBlock
Margin="0,12,0,4"
Style="{StaticResource BodyStrongTextBlockStyle}"
+1
View File
@@ -29,6 +29,7 @@ public static class ServicesContainerSetup
services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>();
services.AddSingleton<IWinoAccountApiClient, WinoAccountApiClient>();
services.AddTransient<IWinoAccountProfileService, WinoAccountProfileService>();
services.AddTransient<IWinoAddOnService, WinoAddOnService>();
services.AddSingleton<IContactPictureFileService, ContactPictureFileService>();
services.AddTransient<ICalDavClient, CalDavClient>();
+92 -12
View File
@@ -11,9 +11,12 @@ using System.Text.Json.Serialization.Metadata;
using System.Threading;
using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Billing;
using Wino.Mail.Api.Contracts.Common;
namespace Wino.Services;
@@ -46,13 +49,13 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
_ownsHttpClient = true;
}
public Task<ApiEnvelope<AuthResultDto>> RegisterAsync(string email, string password, CancellationToken cancellationToken = default)
public Task<WinoAccountApiResult<AuthResultDto>> RegisterAsync(string email, string password, CancellationToken cancellationToken = default)
=> SendAuthRequestAsync("api/v1/auth/register", new RegisterRequest(email, password), WinoAccountApiJsonContext.Default.RegisterRequest, cancellationToken);
public Task<ApiEnvelope<AuthResultDto>> LoginAsync(string email, string password, CancellationToken cancellationToken = default)
public Task<WinoAccountApiResult<AuthResultDto>> LoginAsync(string email, string password, CancellationToken cancellationToken = default)
=> SendAuthRequestAsync("api/v1/auth/login", new LoginRequest(email, password), WinoAccountApiJsonContext.Default.LoginRequest, cancellationToken);
public Task<ApiEnvelope<AuthResultDto>> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default)
public Task<WinoAccountApiResult<AuthResultDto>> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default)
=> SendAuthRequestAsync("api/v1/auth/refresh", new RefreshRequest(refreshToken), WinoAccountApiJsonContext.Default.RefreshRequest, cancellationToken);
public async Task<ApiEnvelope<JsonElement>> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default)
@@ -84,20 +87,27 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
public Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default)
=> SendAuthorizedRequestAsync("api/v1/ai/status", WinoAccountApiJsonContext.Default.ApiEnvelopeAiStatusResultDto, cancellationToken);
public Task<ApiEnvelope<string>> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default)
public Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default)
{
var endpoint = productId switch
{
"ai-pack-monthly" => "api/v1/billing/ai-pack/checkout-session",
"unlimited-accounts" => "api/v1/billing/unlimited-accounts/checkout-session",
WinoAddOnProductType.AI_PACK => "api/v1/billing/ai-pack/checkout-session",
WinoAddOnProductType.UNLIMITED_ACCOUNTS => "api/v1/billing/unlimited-accounts/checkout-session",
_ => string.Empty
};
return string.IsNullOrWhiteSpace(endpoint)
? Task.FromResult(ApiEnvelope<string>.Failure("UnknownProduct"))
: SendAuthorizedRequestAsync(HttpMethod.Post, endpoint, WinoAccountApiJsonContext.Default.ApiEnvelopeString, cancellationToken);
? Task.FromResult(ApiEnvelope<CheckoutSessionResultDto>.Failure("UnknownProduct"))
: SendAuthorizedRequestAsync(HttpMethod.Post, endpoint, WinoAccountApiJsonContext.Default.ApiEnvelopeCheckoutSessionResultDto, cancellationToken);
}
public Task<ApiEnvelope<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default)
=> SendAuthorizedRequestAsync(
HttpMethod.Post,
"api/v1/billing/ai-pack/customer-portal-session",
WinoAccountApiJsonContext.Default.ApiEnvelopeCustomerPortalResultDto,
cancellationToken);
public async Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default)
{
try
@@ -141,7 +151,7 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
}
}
private async Task<ApiEnvelope<AuthResultDto>> SendAuthRequestAsync<TRequest>(string endpoint, TRequest request, JsonTypeInfo<TRequest> typeInfo, CancellationToken cancellationToken)
private async Task<WinoAccountApiResult<AuthResultDto>> SendAuthRequestAsync<TRequest>(string endpoint, TRequest request, JsonTypeInfo<TRequest> typeInfo, CancellationToken cancellationToken)
{
try
{
@@ -156,14 +166,83 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
? null
: JsonSerializer.Deserialize(payload, WinoAccountApiJsonContext.Default.ApiEnvelopeAuthResultDto);
return envelope ?? ApiEnvelope<AuthResultDto>.Failure($"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim());
if (envelope?.IsSuccess == true && envelope.Result != null)
{
return WinoAccountApiResult<AuthResultDto>.Success(envelope.Result);
}
var errorCode = envelope?.ErrorCode ?? $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".Trim();
var errorMessage = ExtractErrorMessage(payload) ?? response.ReasonPhrase;
return WinoAccountApiResult<AuthResultDto>.Failure(errorCode, errorMessage);
}
catch (Exception ex)
{
return ApiEnvelope<AuthResultDto>.Failure(ex.Message);
return WinoAccountApiResult<AuthResultDto>.Failure(ex.GetType().Name, ex.Message);
}
}
private static string? ExtractErrorMessage(string? payload)
{
if (string.IsNullOrWhiteSpace(payload))
{
return null;
}
try
{
using var document = JsonDocument.Parse(payload);
return TryGetErrorMessage(document.RootElement);
}
catch (JsonException)
{
return null;
}
}
private static string? TryGetErrorMessage(JsonElement element)
{
if (TryGetStringProperty(element, "errorMessage", out var errorMessage))
{
return errorMessage;
}
if (TryGetStringProperty(element, "message", out var message))
{
return message;
}
if (TryGetStringProperty(element, "detail", out var detail))
{
return detail;
}
if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty("error", out var errorElement))
{
return TryGetErrorMessage(errorElement);
}
return null;
}
private static bool TryGetStringProperty(JsonElement element, string propertyName, out string? value)
{
value = null;
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var property))
{
return false;
}
if (property.ValueKind != JsonValueKind.String)
{
return false;
}
value = property.GetString();
return !string.IsNullOrWhiteSpace(value);
}
private Task<ApiEnvelope<TResponse>> SendAuthorizedRequestAsync<TResponse>(string endpoint, JsonTypeInfo<ApiEnvelope<TResponse>> typeInfo, CancellationToken cancellationToken)
=> SendAuthorizedRequestAsync(HttpMethod.Get, endpoint, typeInfo, cancellationToken);
@@ -234,6 +313,7 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
[JsonSerializable(typeof(ApiEnvelope<AuthResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<AuthUserDto>))]
[JsonSerializable(typeof(ApiEnvelope<AiStatusResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<string>))]
[JsonSerializable(typeof(ApiEnvelope<CheckoutSessionResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<CustomerPortalResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<JsonElement>))]
internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext;
+82 -6
View File
@@ -1,13 +1,16 @@
#nullable enable
using System;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Billing;
using Wino.Mail.Api.Contracts.Common;
using Wino.Messaging.UI;
@@ -16,11 +19,15 @@ namespace Wino.Services;
public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccountProfileService
{
private readonly IWinoAccountApiClient _apiClient;
private readonly IStoreManagementService _storeManagementService;
private readonly ILogger _logger = Log.ForContext<WinoAccountProfileService>();
public WinoAccountProfileService(IDatabaseService databaseService, IWinoAccountApiClient apiClient) : base(databaseService)
public WinoAccountProfileService(IDatabaseService databaseService,
IWinoAccountApiClient apiClient,
IStoreManagementService storeManagementService) : base(databaseService)
{
_apiClient = apiClient;
_storeManagementService = storeManagementService;
}
public async Task<WinoAccountOperationResult> RegisterAsync(string email, string password, CancellationToken cancellationToken = default)
@@ -108,6 +115,16 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
public async Task<bool> HasActiveAccountAsync()
=> await Connection.Table<WinoAccount>().CountAsync().ConfigureAwait(false) > 0;
public async Task<bool> HasAddOnAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default)
{
return productId switch
{
WinoAddOnProductType.AI_PACK => await HasAiPackAsync(cancellationToken).ConfigureAwait(false),
WinoAddOnProductType.UNLIMITED_ACCOUNTS => await HasUnlimitedAccountsAsync(cancellationToken).ConfigureAwait(false),
_ => false
};
}
public async Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default)
{
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
@@ -142,12 +159,12 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
return response;
}
public async Task<ApiEnvelope<string>> CreateCheckoutSessionAsync(string productId, CancellationToken cancellationToken = default)
public async Task<ApiEnvelope<CheckoutSessionResultDto>> CreateCheckoutSessionAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default)
{
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
if (account == null)
{
return ApiEnvelope<string>.Failure("MissingAccessToken");
return ApiEnvelope<CheckoutSessionResultDto>.Failure("MissingAccessToken");
}
var response = await _apiClient.CreateCheckoutSessionAsync(productId, cancellationToken).ConfigureAwait(false);
@@ -159,6 +176,23 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
return response;
}
public async Task<ApiEnvelope<CustomerPortalResultDto>> CreateCustomerPortalSessionAsync(CancellationToken cancellationToken = default)
{
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
if (account == null)
{
return ApiEnvelope<CustomerPortalResultDto>.Failure("MissingAccessToken");
}
var response = await _apiClient.CreateCustomerPortalSessionAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccess)
{
_logger.Warning("Failed to create customer portal session for Wino account {Email}. Error code: {ErrorCode}", account.Email, response.ErrorCode);
}
return response;
}
public async Task SignOutAsync(CancellationToken cancellationToken = default)
{
var account = await GetActiveAccountAsync().ConfigureAwait(false);
@@ -187,12 +221,12 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
}
}
private async Task<WinoAccountOperationResult> PersistResponseAsync(ApiEnvelope<AuthResultDto> response)
private async Task<WinoAccountOperationResult> PersistResponseAsync(WinoAccountApiResult<AuthResultDto> response)
{
if (!response.IsSuccess || response.Result == null)
{
_logger.Warning("Wino account operation failed. Error code: {ErrorCode}", response.ErrorCode);
return WinoAccountOperationResult.Failure(response.ErrorCode);
_logger.Warning("Wino account operation failed. Error code: {ErrorCode}. Error message: {ErrorMessage}", response.ErrorCode, response.ErrorMessage);
return WinoAccountOperationResult.Failure(response.ErrorCode, response.ErrorMessage);
}
var account = Map(response.Result);
@@ -203,6 +237,48 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
return WinoAccountOperationResult.Success(account);
}
private async Task<bool> HasAiPackAsync(CancellationToken cancellationToken)
{
var response = await GetAiStatusAsync(cancellationToken).ConfigureAwait(false);
return response.IsSuccess && response.Result?.HasAiPack == true;
}
private async Task<bool> HasUnlimitedAccountsAsync(CancellationToken cancellationToken)
{
if (await HasRemoteUnlimitedAccountsAsync(cancellationToken).ConfigureAwait(false))
{
return true;
}
return await _storeManagementService.HasProductAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS).ConfigureAwait(false);
}
private async Task<bool> HasRemoteUnlimitedAccountsAsync(CancellationToken cancellationToken)
{
var response = await GetCurrentUserAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccess || response.Result == null)
{
return false;
}
return TryGetBooleanProperty(response.Result, "HasUnlimitedAccounts", out var hasUnlimitedAccounts) && hasUnlimitedAccounts;
}
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2075", Justification = "The reflected contract property is a stable API field read from a concrete DTO instance.")]
private static bool TryGetBooleanProperty(object instance, string propertyName, out bool value)
{
value = false;
var property = instance.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public);
if (property?.PropertyType != typeof(bool))
{
return false;
}
value = (bool)(property.GetValue(instance) ?? false);
return true;
}
private static WinoAccount Map(AuthResultDto result)
=> new()
{
+52
View File
@@ -0,0 +1,52 @@
#nullable enable
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
namespace Wino.Services;
public sealed class WinoAddOnService : IWinoAddOnService
{
private static readonly WinoAddOnProductType[] AvailableAddOns =
[
WinoAddOnProductType.AI_PACK,
WinoAddOnProductType.UNLIMITED_ACCOUNTS
];
private readonly IWinoAccountProfileService _profileService;
public WinoAddOnService(IWinoAccountProfileService profileService)
{
_profileService = profileService;
}
public async Task<IReadOnlyList<WinoAddOnInfo>> GetAvailableAddOnsAsync(CancellationToken cancellationToken = default)
{
var aiStatusTask = _profileService.GetAiStatusAsync(cancellationToken);
var hasUnlimitedAccountsTask = _profileService.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS, cancellationToken);
await Task.WhenAll(aiStatusTask, hasUnlimitedAccountsTask).ConfigureAwait(false);
var aiStatusResponse = await aiStatusTask.ConfigureAwait(false);
var aiStatus = aiStatusResponse.IsSuccess ? aiStatusResponse.Result : null;
return
[
new WinoAddOnInfo(
WinoAddOnProductType.AI_PACK,
aiStatus?.HasAiPack == true,
aiStatus?.Used,
aiStatus?.MonthlyLimit,
aiStatus?.HasAiPack == true && aiStatus.MonthlyLimit is int limit && limit > 0 && aiStatus.Used is int used
? (double)used / limit * 100
: 0,
aiStatus?.CurrentPeriodEndUtc),
new WinoAddOnInfo(
WinoAddOnProductType.UNLIMITED_ACCOUNTS,
await hasUnlimitedAccountsTask.ConfigureAwait(false))
];
}
}