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 - Wrap async operations in try-catch
- Log errors via IWinoLogger - Log errors via IWinoLogger
- For dependency properties in WinUI code, always prefer `[GeneratedDependencyProperty]` from CommunityToolkit over manual `DependencyProperty.Register(...)` declarations. - 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)`). - 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`. - 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(...)`. - 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 System.Threading.Tasks;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Store;
namespace Wino.Core.Domain.Interfaces; namespace Wino.Core.Domain.Interfaces;
public interface IStoreManagementService public interface IStoreManagementService
@@ -9,10 +7,10 @@ public interface IStoreManagementService
/// <summary> /// <summary>
/// Checks whether user has the type of an add-on purchased. /// Checks whether user has the type of an add-on purchased.
/// </summary> /// </summary>
Task<bool> HasProductAsync(StoreProductType productType); Task<bool> HasProductAsync(WinoAddOnProductType productType);
/// <summary> /// <summary>
/// Attempts to purchase the given add-on. /// Attempts to purchase the given add-on.
/// </summary> /// </summary>
Task<StorePurchaseResult> PurchaseAsync(StoreProductType productType); Task<StorePurchaseResult> PurchaseAsync(WinoAddOnProductType productType);
} }
@@ -2,21 +2,25 @@
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; 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.Ai;
using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Billing;
using Wino.Mail.Api.Contracts.Common; using Wino.Mail.Api.Contracts.Common;
namespace Wino.Core.Domain.Interfaces; namespace Wino.Core.Domain.Interfaces;
public interface IWinoAccountApiClient public interface IWinoAccountApiClient
{ {
Task<ApiEnvelope<AuthResultDto>> RegisterAsync(string email, string password, CancellationToken cancellationToken = default); Task<WinoAccountApiResult<AuthResultDto>> RegisterAsync(string email, string password, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AuthResultDto>> LoginAsync(string email, string password, CancellationToken cancellationToken = default); Task<WinoAccountApiResult<AuthResultDto>> LoginAsync(string email, string password, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AuthResultDto>> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default); Task<WinoAccountApiResult<AuthResultDto>> RefreshAsync(string refreshToken, CancellationToken cancellationToken = default);
Task<ApiEnvelope<JsonElement>> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default); Task<ApiEnvelope<JsonElement>> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default); Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(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<string?> GetSettingsAsync(CancellationToken cancellationToken = default);
Task<bool> SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default); Task<bool> SaveSettingsAsync(string settingsJson, CancellationToken cancellationToken = default);
} }
@@ -2,9 +2,11 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Billing;
using Wino.Mail.Api.Contracts.Common; using Wino.Mail.Api.Contracts.Common;
namespace Wino.Core.Domain.Interfaces; namespace Wino.Core.Domain.Interfaces;
@@ -17,8 +19,10 @@ public interface IWinoAccountProfileService
Task<WinoAccount?> GetActiveAccountAsync(); Task<WinoAccount?> GetActiveAccountAsync();
Task<WinoAccount?> GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default); Task<WinoAccount?> GetAuthenticatedAccountAsync(CancellationToken cancellationToken = default);
Task<bool> HasActiveAccountAsync(); Task<bool> HasActiveAccountAsync();
Task<bool> HasAddOnAsync(WinoAddOnProductType productId, CancellationToken cancellationToken = default);
Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default); Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default);
Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(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); 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 bool IsSuccess { get; init; }
public string? ErrorCode { get; init; } public string? ErrorCode { get; init; }
public string? ErrorMessage { get; init; }
public WinoAccount? Account { get; init; } public WinoAccount? Account { get; init; }
public static WinoAccountOperationResult Success(WinoAccount account) public static WinoAccountOperationResult Success(WinoAccount account)
@@ -16,10 +17,11 @@ public sealed class WinoAccountOperationResult
Account = account Account = account
}; };
public static WinoAccountOperationResult Failure(string? errorCode) public static WinoAccountOperationResult Failure(string? errorCode, string? errorMessage = null)
=> new() => new()
{ {
IsSuccess = false, 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_SyncAliases": "Synchronize Aliases",
"Buttons_TryAgain": "Try Again", "Buttons_TryAgain": "Try Again",
"Buttons_Yes": "Yes", "Buttons_Yes": "Yes",
"Purchased": "Purchased",
"Sync_SynchronizingFolder": "Synchronizing {0} {1}%", "Sync_SynchronizingFolder": "Synchronizing {0} {1}%",
"Sync_DownloadedMessages": "Downloaded {0} messages from {1}", "Sync_DownloadedMessages": "Downloaded {0} messages from {1}",
"SyncAction_Archiving": "Archiving {0} mail(s)", "SyncAction_Archiving": "Archiving {0} mail(s)",
@@ -860,7 +861,7 @@
"WinoAccount_Management_SignedOutTitle": "Sign in to Wino Mail", "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_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_ProfileSectionHeader": "Profile",
"WinoAccount_Management_AiPackSectionHeader": "AI Pack", "WinoAccount_Management_AddOnsSectionHeader": "Wino Add-Ons",
"WinoAccount_Management_DataSectionHeader": "Data", "WinoAccount_Management_DataSectionHeader": "Data",
"WinoAccount_Management_AccountActionsSectionHeader": "Account actions", "WinoAccount_Management_AccountActionsSectionHeader": "Account actions",
"WinoAccount_Management_AccountCardTitle": "Account", "WinoAccount_Management_AccountCardTitle": "Account",
@@ -887,14 +888,10 @@
"WinoAccount_Management_AiPackFeatureTranslate": "Translate", "WinoAccount_Management_AiPackFeatureTranslate": "Translate",
"WinoAccount_Management_AiPackFeatureRewrite": "Rewrite", "WinoAccount_Management_AiPackFeatureRewrite": "Rewrite",
"WinoAccount_Management_AiPackFeatureSummarize": "Summarize", "WinoAccount_Management_AiPackFeatureSummarize": "Summarize",
"WinoAccount_Management_ImportSettingsTitle": "Import settings", "WinoAccount_Management_SyncPreferencesTitle": "Synchronize Preferences",
"WinoAccount_Management_ImportSettingsDescription": "Restore your preferences from cloud.", "WinoAccount_Management_SyncPreferencesDescription": "Import or export your preferences to cloud. Import them across devices.",
"WinoAccount_Management_ExportSettingsTitle": "Export settings",
"WinoAccount_Management_ExportSettingsDescription": "Save your preferences to cloud.",
"WinoAccount_Management_SignOutTitle": "Sign out", "WinoAccount_Management_SignOutTitle": "Sign out",
"WinoAccount_Management_SignOutDescription": "Sign out of your account on this device", "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_StatusLabel": "Status: {0}",
"WinoAccount_Management_NoRemoteSettings": "There are no synchronized settings stored for this account yet.", "WinoAccount_Management_NoRemoteSettings": "There are no synchronized settings stored for this account yet.",
"WinoAccount_Management_ExportSucceeded": "Your settings were exported to your Wino Account.", "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_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_LoadFailed": "Wino could not load the latest Wino Account information.",
"WinoAccount_Management_ActionFailed": "The Wino Account request could not be completed.", "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_MessageList_Keywords": "message;messages;list;threading;threads;avatar;preview;sender",
"SettingsSearch_ReadComposePane_Keywords": "reader;compose;composer;font;fonts;external content;display;reading", "SettingsSearch_ReadComposePane_Keywords": "reader;compose;composer;font;fonts;external content;display;reading",
"SettingsSearch_SignatureAndEncryption_Keywords": "signature;signatures;encryption;certificate;certificates;s mime;smime;security", "SettingsSearch_SignatureAndEncryption_Keywords": "signature;signatures;encryption;certificate;certificates;s mime;smime;security",
@@ -3,7 +3,9 @@ using System.Threading.Tasks;
using FluentAssertions; using FluentAssertions;
using Moq; using Moq;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Common; using Wino.Mail.Api.Contracts.Common;
using Wino.Services; using Wino.Services;
@@ -15,6 +17,7 @@ namespace Wino.Core.Tests.Services;
public class WinoAccountProfileServiceTests : IAsyncLifetime public class WinoAccountProfileServiceTests : IAsyncLifetime
{ {
private readonly Mock<IWinoAccountApiClient> _apiClient = new(); private readonly Mock<IWinoAccountApiClient> _apiClient = new();
private readonly Mock<IStoreManagementService> _storeManagementService = new();
private InMemoryDatabaseService _databaseService = null!; private InMemoryDatabaseService _databaseService = null!;
private WinoAccountProfileService _service = null!; private WinoAccountProfileService _service = null!;
@@ -22,7 +25,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
{ {
_databaseService = new InMemoryDatabaseService(); _databaseService = new InMemoryDatabaseService();
await _databaseService.InitializeAsync(); await _databaseService.InitializeAsync();
_service = new WinoAccountProfileService(_databaseService, _apiClient.Object); _service = new WinoAccountProfileService(_databaseService, _apiClient.Object, _storeManagementService.Object);
} }
public async Task DisposeAsync() public async Task DisposeAsync()
@@ -37,7 +40,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
_apiClient _apiClient
.Setup(x => x.LoginAsync("first@example.com", "pw", default)) .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"); var result = await _service.LoginAsync("first@example.com", "pw");
@@ -56,11 +59,11 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
{ {
_apiClient _apiClient
.Setup(x => x.LoginAsync("first@example.com", "pw", default)) .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 _apiClient
.Setup(x => x.LoginAsync("second@example.com", "pw", default)) .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("first@example.com", "pw");
await _service.LoginAsync("second@example.com", "pw"); await _service.LoginAsync("second@example.com", "pw");
@@ -77,7 +80,7 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
_apiClient _apiClient
.Setup(x => x.LoginAsync("signout@example.com", "pw", default)) .Setup(x => x.LoginAsync("signout@example.com", "pw", default))
.ReturnsAsync(ApiEnvelope<AuthResultDto>.Success(authResult)); .ReturnsAsync(WinoAccountApiResult<AuthResultDto>.Success(authResult));
_apiClient _apiClient
.Setup(x => x.LogoutAsync(authResult.RefreshToken, default)) .Setup(x => x.LogoutAsync(authResult.RefreshToken, default))
@@ -90,6 +93,32 @@ public class WinoAccountProfileServiceTests : IAsyncLifetime
persisted.Should().BeEmpty(); 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) private static AuthResultDto CreateAuthResult(string email)
{ {
return new AuthResultDto( return new AuthResultDto(
@@ -9,7 +9,6 @@ using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Navigation;
using Wino.Core.Domain.Models.Store;
using Wino.Mail.ViewModels.Data; using Wino.Mail.ViewModels.Data;
using Wino.Messaging.Client.Navigation; using Wino.Messaging.Client.Navigation;
@@ -44,6 +43,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
protected IAccountService AccountService { get; } protected IAccountService AccountService { get; }
protected IProviderService ProviderService { get; } protected IProviderService ProviderService { get; }
protected IStoreManagementService StoreManagementService { get; } protected IStoreManagementService StoreManagementService { get; }
protected IWinoAccountProfileService WinoAccountProfileService { get; }
protected IAuthenticationProvider AuthenticationProvider { get; } protected IAuthenticationProvider AuthenticationProvider { get; }
protected IPreferencesService PreferencesService { get; } protected IPreferencesService PreferencesService { get; }
@@ -52,6 +52,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
IAccountService accountService, IAccountService accountService,
IProviderService providerService, IProviderService providerService,
IStoreManagementService storeManagementService, IStoreManagementService storeManagementService,
IWinoAccountProfileService winoAccountProfileService,
IAuthenticationProvider authenticationProvider, IAuthenticationProvider authenticationProvider,
IPreferencesService preferencesService) IPreferencesService preferencesService)
{ {
@@ -60,6 +61,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
AccountService = accountService; AccountService = accountService;
ProviderService = providerService; ProviderService = providerService;
StoreManagementService = storeManagementService; StoreManagementService = storeManagementService;
WinoAccountProfileService = winoAccountProfileService;
AuthenticationProvider = authenticationProvider; AuthenticationProvider = authenticationProvider;
PreferencesService = preferencesService; PreferencesService = preferencesService;
} }
@@ -75,7 +77,7 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
[RelayCommand] [RelayCommand]
public async Task PurchaseUnlimitedAccountAsync() public async Task PurchaseUnlimitedAccountAsync()
{ {
var purchaseResult = await StoreManagementService.PurchaseAsync(StoreProductType.UnlimitedAccounts); var purchaseResult = await StoreManagementService.PurchaseAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS);
if (purchaseResult == StorePurchaseResult.Succeeded) if (purchaseResult == StorePurchaseResult.Succeeded)
DialogService.InfoBarMessage(Translator.Info_PurchaseThankYouTitle, Translator.Info_PurchaseThankYouMessage, InfoBarMessageType.Success); DialogService.InfoBarMessage(Translator.Info_PurchaseThankYouTitle, Translator.Info_PurchaseThankYouMessage, InfoBarMessageType.Success);
@@ -92,14 +94,12 @@ public abstract partial class AccountManagementPageViewModelBase : CoreBaseViewM
public async Task ManageStorePurchasesAsync() public async Task ManageStorePurchasesAsync()
{ {
await ExecuteUIThread(async () => var hasUnlimitedAccountProduct = await WinoAccountProfileService.HasAddOnAsync(WinoAddOnProductType.UNLIMITED_ACCOUNTS).ConfigureAwait(false);
{
HasUnlimitedAccountProduct = await StoreManagementService.HasProductAsync(StoreProductType.UnlimitedAccounts);
if (!HasUnlimitedAccountProduct) await ExecuteUIThread(() =>
IsAccountCreationBlocked = Accounts.Count >= FREE_ACCOUNT_COUNT; {
else HasUnlimitedAccountProduct = hasUnlimitedAccountProduct;
IsAccountCreationBlocked = false; 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 #nullable enable
using System; using System;
using System.Globalization; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging; using CommunityToolkit.Mvvm.Messaging;
using Wino.Core.Domain; using Wino.Core.Domain;
using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums; using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Core.Domain.Models.Navigation; using Wino.Core.Domain.Models.Navigation;
using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Billing;
using Wino.Mail.Api.Contracts.Auth; using Wino.Core.ViewModels.Data;
using Wino.Messaging.UI; using Wino.Messaging.UI;
namespace Wino.Core.ViewModels; namespace Wino.Core.ViewModels;
@@ -20,27 +22,20 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
IRecipient<WinoAccountSignedInMessage>, IRecipient<WinoAccountSignedInMessage>,
IRecipient<WinoAccountSignedOutMessage> 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 IWinoAccountProfileService _profileService;
private readonly IWinoAddOnService _addOnService;
private readonly IMailDialogService _dialogService; private readonly IMailDialogService _dialogService;
private readonly INativeAppService _nativeAppService; private readonly INativeAppService _nativeAppService;
public ObservableCollection<WinoAddOnItemViewModel> AddOns { get; } = [];
[ObservableProperty] [ObservableProperty]
public partial bool IsBusy { get; set; } public partial bool IsBusy { get; set; }
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsSignedOut))] [NotifyPropertyChangedFor(nameof(IsSignedOut))]
[NotifyPropertyChangedFor(nameof(CanShowBuyAiPack))]
public partial bool IsSignedIn { get; set; } 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] [ObservableProperty]
public partial string AccountEmail { get; set; } = string.Empty; 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; public partial string AccountStatusText { get; set; } = string.Empty;
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanShowAiUsage))] [NotifyCanExecuteChangedFor(nameof(PurchaseAddOnCommand))]
[NotifyPropertyChangedFor(nameof(CanShowBuyAiPack))] public partial bool IsCheckoutInProgress { get; set; }
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; }
public bool IsSignedOut => !IsSignedIn; public bool IsSignedOut => !IsSignedIn;
public bool CanShowAiUsage => HasAiPack;
public bool CanShowBuyAiPack => IsSignedIn && !HasAiPack;
public bool CanBuyAiPack => !IsAiPackCheckoutInProgress;
public WinoAccountManagementPageViewModel(IWinoAccountProfileService profileService, public WinoAccountManagementPageViewModel(IWinoAccountProfileService profileService,
IWinoAddOnService addOnService,
IMailDialogService dialogService, IMailDialogService dialogService,
INativeAppService nativeAppService) INativeAppService nativeAppService)
{ {
_profileService = profileService; _profileService = profileService;
_addOnService = addOnService;
_dialogService = dialogService; _dialogService = dialogService;
_nativeAppService = nativeAppService; _nativeAppService = nativeAppService;
} }
@@ -147,16 +112,15 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
InfoBarMessageType.Success); InfoBarMessageType.Success);
} }
[RelayCommand] [RelayCommand(CanExecute = nameof(CanPurchaseAddOn))]
private async Task BuyAiPackAsync() private async Task PurchaseAddOnAsync(WinoAddOnItemViewModel? addOn)
{ {
if (IsAiPackCheckoutInProgress) if (addOn == null)
{ {
return; return;
} }
var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false); var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false);
if (account == null) if (account == null)
{ {
_dialogService.InfoBarMessage(Translator.GeneralTitle_Warning, _dialogService.InfoBarMessage(Translator.GeneralTitle_Warning,
@@ -165,13 +129,17 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
return; return;
} }
await ExecuteUIThread(() => IsAiPackCheckoutInProgress = true); await ExecuteUIThread(() =>
{
IsCheckoutInProgress = true;
addOn.IsPurchaseInProgress = true;
});
try 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, _dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
Translator.WinoAccount_Management_PurchaseStartFailed, Translator.WinoAccount_Management_PurchaseStartFailed,
@@ -179,8 +147,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
return; 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) if (!isLaunched)
{ {
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, _dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
@@ -199,12 +166,49 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
} }
finally finally
{ {
await ExecuteUIThread(() => IsAiPackCheckoutInProgress = false); await ExecuteUIThread(() =>
{
IsCheckoutInProgress = false;
addOn.IsPurchaseInProgress = false;
});
} }
} }
private bool CanPurchaseAddOn(WinoAddOnItemViewModel? addOn)
=> addOn != null && !addOn.IsPurchased && !IsCheckoutInProgress;
[RelayCommand] [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] [RelayCommand]
private Task ExportSettingsAsync() => Task.CompletedTask; private Task ExportSettingsAsync() => Task.CompletedTask;
@@ -232,7 +236,7 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
=> _ = LoadAsync(); => _ = LoadAsync();
public void Receive(WinoAccountSignedOutMessage message) public void Receive(WinoAccountSignedOutMessage message)
=> _ = ResetSignedOutStateAsync(); => _ = LoadAsync();
private async Task LoadAsync() private async Task LoadAsync()
{ {
@@ -241,44 +245,25 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
try try
{ {
var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false); var account = await _profileService.GetAuthenticatedAccountAsync().ConfigureAwait(false);
var addOns = await _addOnService.GetAvailableAddOnsAsync().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);
await ExecuteUIThread(() => await ExecuteUIThread(() =>
{ {
IsSignedIn = true; IsSignedIn = account != null;
AccountEmail = resolvedUser.Email; AccountEmail = account?.Email ?? string.Empty;
AccountDisplayName = ExtractDisplayName(resolvedUser.Email); AccountStatusText = account == null
AccountInitials = ExtractInitials(resolvedUser.Email); ? string.Empty
AccountStatusText = string.Format(Translator.WinoAccount_Management_StatusLabel, resolvedUser.AccountStatus); : string.Format(Translator.WinoAccount_Management_StatusLabel, account.AccountStatus);
}); });
UpdateAiPackState(aiStatusResponse.IsSuccess ? aiStatusResponse.Result : null); await UpdateAddOnsAsync(addOns).ConfigureAwait(false);
if (!currentUserResponse.IsSuccess || !aiStatusResponse.IsSuccess)
{
_dialogService.InfoBarMessage(Translator.GeneralTitle_Warning,
Translator.WinoAccount_Management_LoadFailed,
InfoBarMessageType.Warning);
}
} }
catch (Exception) catch (Exception)
{ {
_dialogService.InfoBarMessage(Translator.GeneralTitle_Error, _dialogService.InfoBarMessage(Translator.GeneralTitle_Error,
Translator.WinoAccount_Management_LoadFailed, Translator.WinoAccount_Management_LoadFailed,
InfoBarMessageType.Error); InfoBarMessageType.Error);
await ResetSignedOutStateAsync(); await ResetStateAsync().ConfigureAwait(false);
} }
finally finally
{ {
@@ -286,89 +271,54 @@ public partial class WinoAccountManagementPageViewModel : CoreBaseViewModel,
} }
} }
private async Task ResetSignedOutStateAsync() private async Task ResetStateAsync()
{ {
await ExecuteUIThread(() => await ExecuteUIThread(() =>
{ {
IsSignedIn = false; IsSignedIn = false;
AccountDisplayName = string.Empty;
AccountInitials = string.Empty;
AccountEmail = string.Empty; AccountEmail = string.Empty;
AccountStatusText = string.Empty; AccountStatusText = string.Empty;
HasAiPack = false; IsCheckoutInProgress = false;
AiPackStateText = Translator.WinoAccount_Management_AiPackInactive; AddOns.Clear();
AiUsageSummary = string.Empty; PurchaseAddOnCommand.NotifyCanExecuteChanged();
AiBillingPeriodSummary = string.Empty;
AiPackRenewalText = string.Empty;
AiUsageCount = 0;
AiUsageLimit = 1;
AiUsagePercentage = 0;
AiUsageResetText = string.Empty;
IsAiPackCheckoutInProgress = false;
}); });
} }
private void UpdateAiPackState(AiStatusResultDto? aiStatus) private async Task UpdateAddOnsAsync(IReadOnlyList<WinoAddOnInfo> addOns)
{ {
var hasAiPack = aiStatus?.HasAiPack == true; var items = addOns.Select(CreateAddOnItem).ToList();
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;
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); AddOns.Clear();
usageCount = used;
usageLimit = limit > 0 ? limit : 1;
usagePercentage = (double)used / usageLimit * 100;
}
if (hasAiPack && aiStatus?.CurrentPeriodStartUtc is DateTimeOffset periodStart && aiStatus.CurrentPeriodEndUtc is DateTimeOffset periodEnd) foreach (var item in items)
{ {
billingText = string.Format(Translator.WinoAccount_Management_AiPackBillingPeriod, periodStart.LocalDateTime, periodEnd.LocalDateTime); AddOns.Add(item);
renewalText = string.Format(Translator.WinoAccount_Management_AiPackRenews, periodEnd.LocalDateTime); }
resetText = string.Format(Translator.WinoAccount_Management_AiPackResets, periodEnd.LocalDateTime);
}
_ = ExecuteUIThread(() => PurchaseAddOnCommand.NotifyCanExecuteChanged();
{
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;
}); });
} }
private static string ExtractDisplayName(string email) private WinoAddOnItemViewModel CreateAddOnItem(WinoAddOnInfo addOn)
{ {
if (string.IsNullOrWhiteSpace(email)) var item = new WinoAddOnItemViewModel(addOn.ProductType)
return 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
};
var atIndex = email.IndexOf('@'); if (addOn.RenewalDateUtc is DateTimeOffset renewalDateUtc)
var localPart = atIndex > 0 ? email[..atIndex] : email; {
item.RenewalText = string.Format(Translator.WinoAccount_Management_AiPackRenews, renewalDateUtc.LocalDateTime);
item.UsageResetText = string.Format(Translator.WinoAccount_Management_AiPackResets, renewalDateUtc.LocalDateTime);
}
if (localPart.Length == 0) return item;
return string.Empty;
return char.ToUpper(localPart[0], CultureInfo.CurrentCulture) + localPart[1..];
}
private static string ExtractInitials(string email)
{
var displayName = ExtractDisplayName(email);
return displayName.Length > 0
? displayName[..1].ToUpper(CultureInfo.CurrentCulture)
: string.Empty;
} }
} }
@@ -37,11 +37,12 @@ public partial class AccountManagementViewModel : AccountManagementPageViewModel
IAccountService accountService, IAccountService accountService,
IProviderService providerService, IProviderService providerService,
IStoreManagementService storeManagementService, IStoreManagementService storeManagementService,
IWinoAccountProfileService winoAccountProfileService,
IWinoLogger winoLogger, IWinoLogger winoLogger,
ISpecialImapProviderConfigResolver specialImapProviderConfigResolver, ISpecialImapProviderConfigResolver specialImapProviderConfigResolver,
ICalDavClient calDavClient, ICalDavClient calDavClient,
IAuthenticationProvider authenticationProvider, 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; MailDialogService = dialogService;
_winoLogger = winoLogger; _winoLogger = winoLogger;
@@ -62,7 +62,7 @@ public sealed partial class WinoAccountLoginDialog : ContentDialog
if (!result.IsSuccess || result.Account == null) if (!result.IsSuccess || result.Account == null)
{ {
ShowError(WinoAccountAuthErrorTranslator.Translate(result.ErrorCode)); ShowError(WinoAccountAuthErrorTranslator.Format(result.ErrorCode, result.ErrorMessage));
return; return;
} }
@@ -64,7 +64,7 @@ public sealed partial class WinoAccountRegistrationDialog : ContentDialog
if (!result.IsSuccess || result.Account == null) if (!result.IsSuccess || result.Account == null)
{ {
ShowError(WinoAccountAuthErrorTranslator.Translate(result.ErrorCode)); ShowError(WinoAccountAuthErrorTranslator.Format(result.ErrorCode, result.ErrorMessage));
return; 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 System.Threading.Tasks;
using Windows.Services.Store; using Windows.Services.Store;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Store;
using WinoStorePurchaseResult = Wino.Core.Domain.Enums.StorePurchaseResult; using WinoStorePurchaseResult = Wino.Core.Domain.Enums.StorePurchaseResult;
using WinoAddOnProductType = Wino.Core.Domain.Enums.WinoAddOnProductType;
namespace Wino.Mail.WinUI.Services; namespace Wino.Mail.WinUI.Services;
@@ -12,14 +12,14 @@ public class StoreManagementService : IStoreManagementService
{ {
private StoreContext CurrentContext { get; } 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() public StoreManagementService()
@@ -27,9 +27,11 @@ public class StoreManagementService : IStoreManagementService
CurrentContext = StoreContext.GetDefault(); 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(); var appLicense = await CurrentContext.GetAppLicenseAsync();
if (appLicense == null) if (appLicense == null)
@@ -49,14 +51,15 @@ public class StoreManagementService : IStoreManagementService
return false; 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)) if (await HasProductAsync(productType))
return WinoStorePurchaseResult.AlreadyPurchased; return WinoStorePurchaseResult.AlreadyPurchased;
else else
{ {
var productKey = skuIds[productType];
var result = await CurrentContext.RequestPurchaseAsync(productKey); var result = await CurrentContext.RequestPurchaseAsync(productKey);
switch (result.Status) switch (result.Status)
@@ -30,4 +30,34 @@ public static class WinoAccountAuthErrorTranslator
_ => errorCode _ => 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,13 +7,172 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:domain="using:Wino.Core.Domain" xmlns:domain="using:Wino.Core.Domain"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 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}" Title="{x:Bind domain:Translator.WinoAccount_SettingsSection_Title}"
mc:Ignorable="d"> mc:Ignorable="d">
<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
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" />
<StackPanel
Margin="0,4,0,0"
Orientation="Horizontal"
Spacing="12">
<Button
Command="{x:Bind PurchaseCommand, Mode=OneWay}"
CommandParameter="{x:Bind}"
Content="{x:Bind domain:Translator.Buttons_Purchase}"
Style="{StaticResource AccentButtonStyle}" />
<ProgressRing
Width="18"
Height="18"
IsActive="{x:Bind IsPurchaseInProgress, Mode=OneWay}"
Visibility="{x:Bind IsPurchaseInProgress, Mode=OneWay}" />
</StackPanel>
</StackPanel>
</controls:SettingsCard.Header>
</controls:SettingsCard>
</DataTemplate>
<DataTemplate x:Key="AiPackPurchasedTemplate" x:DataType="viewModelData:WinoAddOnItemViewModel">
<controls:SettingsExpander IsExpanded="True">
<controls:SettingsExpander.HeaderIcon>
<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.GetTranslatedString(NameKey), Mode=OneWay}" />
<Border
Padding="8,2"
Background="{ThemeResource SystemAccentColor}"
CornerRadius="4">
<TextBlock
FontSize="10"
FontWeight="Bold"
Foreground="White"
Text="PRO" />
</Border>
</StackPanel>
</controls:SettingsExpander.Header>
<controls:SettingsExpander.Description>
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackSubscriptionActive}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text=" · " />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind RenewalText, Mode=OneWay}" />
</StackPanel>
</controls:SettingsExpander.Description>
<HyperlinkButton Command="{x:Bind ManageCommand, Mode=OneWay}" Content="{x:Bind domain:Translator.Buttons_Manage}" />
<controls:SettingsExpander.Items>
<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 UsageCount, Mode=OneWay}" />
<TextBlock
Margin="0,0,0,2"
VerticalAlignment="Bottom"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}">
<Run Text="/ " />
<Run Text="{x:Bind UsageLimit, Mode=OneWay}" />
</TextBlock>
</StackPanel>
<ProgressBar
Height="8"
Maximum="100"
Value="{x:Bind UsagePercentage, Mode=OneWay}" />
<Grid>
<TextBlock
HorizontalAlignment="Left"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackRequestsUsed}" />
<TextBlock
HorizontalAlignment="Right"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
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> <ScrollViewer>
<StackPanel Spacing="{StaticResource SettingsCardSpacing}"> <StackPanel Spacing="{StaticResource SettingsCardSpacing}">
<!-- Busy indicator -->
<StackPanel <StackPanel
x:Name="BusyPanel" x:Name="BusyPanel"
HorizontalAlignment="Center" HorizontalAlignment="Center"
@@ -26,34 +185,29 @@
<TextBlock HorizontalAlignment="Center" Text="{x:Bind domain:Translator.Busy}" /> <TextBlock HorizontalAlignment="Center" Text="{x:Bind domain:Translator.Busy}" />
</StackPanel> </StackPanel>
<!-- ══════════════════════════════════════ -->
<!-- SIGNED-OUT VIEW -->
<!-- ══════════════════════════════════════ -->
<StackPanel <StackPanel
x:Name="SignedOutPanel" x:Name="SignedOutPanel"
HorizontalAlignment="Stretch"
x:Load="{x:Bind ViewModel.IsSignedOut, Mode=OneWay}" x:Load="{x:Bind ViewModel.IsSignedOut, Mode=OneWay}"
Spacing="{StaticResource SettingsCardSpacing}"> Spacing="{StaticResource SettingsCardSpacing}">
<StackPanel <StackPanel
Padding="0,40,0,40" Padding="0,40,0,40"
HorizontalAlignment="Center" HorizontalAlignment="Stretch"
Spacing="16"> Spacing="16">
<!-- App logo -->
<Image <Image
Width="64" Width="64"
Height="64" Height="64"
HorizontalAlignment="Center" HorizontalAlignment="Center"
Source="ms-appx:///Assets/AppEntries/MailAssets/Square150x150Logo.scale-100.png" /> Source="ms-appx:///Assets/AppEntries/MailAssets/Square150x150Logo.scale-100.png" />
<!-- Title -->
<TextBlock <TextBlock
HorizontalAlignment="Center" HorizontalAlignment="Center"
FontSize="20" FontSize="20"
FontWeight="SemiBold" FontWeight="SemiBold"
Text="{x:Bind domain:Translator.WinoAccount_Management_SignedOutTitle}" /> Text="{x:Bind domain:Translator.WinoAccount_Management_SignedOutTitle}" />
<!-- Description -->
<TextBlock <TextBlock
MaxWidth="360" MaxWidth="360"
HorizontalAlignment="Center" HorizontalAlignment="Center"
@@ -63,7 +217,6 @@
TextAlignment="Center" TextAlignment="Center"
TextWrapping="WrapWholeWords" /> TextWrapping="WrapWholeWords" />
<!-- Action buttons -->
<StackPanel <StackPanel
HorizontalAlignment="Center" HorizontalAlignment="Center"
Orientation="Horizontal" Orientation="Horizontal"
@@ -79,127 +232,34 @@
Margin="0,16,0,4" Margin="0,16,0,4"
HorizontalAlignment="Center" HorizontalAlignment="Center"
Style="{StaticResource BodyStrongTextBlockStyle}" Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackSectionHeader}" /> Text="{x:Bind domain:Translator.WinoAccount_Management_AddOnsSectionHeader}" />
<controls:SettingsCard <ListView
MaxWidth="520" x:Name="SignedOutAddOnsList"
Description="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoDescription}"> ItemContainerStyle="{StaticResource StretchedItemContainerStyle}"
<controls:SettingsCard.HeaderIcon> ItemTemplateSelector="{StaticResource WinoAddOnTemplateSelector}"
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE945;" /> ItemsSource="{x:Bind ViewModel.AddOns, Mode=OneWay}"
</controls:SettingsCard.HeaderIcon> SelectionMode="None" />
<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>
<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}"
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>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<!-- ══════════════════════════════════════ -->
<!-- SIGNED-IN VIEW -->
<!-- ══════════════════════════════════════ -->
<StackPanel <StackPanel
x:Name="SignedInPanel" x:Name="SignedInPanel"
x:Load="{x:Bind ViewModel.IsSignedIn, Mode=OneWay}" x:Load="{x:Bind ViewModel.IsSignedIn, Mode=OneWay}"
Spacing="{StaticResource SettingsCardSpacing}"> Spacing="{StaticResource SettingsCardSpacing}">
<!-- ─── Profile section ─── -->
<TextBlock <TextBlock
Margin="0,0,0,4" Margin="0,0,0,4"
Style="{StaticResource BodyStrongTextBlockStyle}" Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_ProfileSectionHeader}" /> Text="{x:Bind domain:Translator.WinoAccount_Management_ProfileSectionHeader}" />
<controls:SettingsCard> <controls:SettingsCard Header="{x:Bind ViewModel.AccountEmail, Mode=OneWay}">
<controls:SettingsCard.Header> <controls:SettingsCard.HeaderIcon>
<StackPanel Orientation="Horizontal" Spacing="12"> <PathIcon
<PersonPicture HorizontalAlignment="Center"
Width="40" VerticalAlignment="Center"
Height="40" 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 " />
Initials="{x:Bind ViewModel.AccountInitials, Mode=OneWay}" /> </controls:SettingsCard.HeaderIcon>
<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 <Border
Padding="12,4" Padding="12,4"
Background="{ThemeResource SystemFillColorSuccessBackgroundBrush}" Background="{ThemeResource SystemFillColorSuccessBackgroundBrush}"
@@ -211,197 +271,36 @@
</Border> </Border>
</controls:SettingsCard> </controls:SettingsCard>
<!-- ─── AI Pack section ─── -->
<TextBlock <TextBlock
Margin="0,12,0,4" Margin="0,12,0,4"
Style="{StaticResource BodyStrongTextBlockStyle}" Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackSectionHeader}" /> Text="{x:Bind domain:Translator.WinoAccount_Management_AddOnsSectionHeader}" />
<!-- AI Pack promo (no AI Pack) --> <ListView
<controls:SettingsCard x:Name="SignedInAddOnsList"
x:Name="AiPackPromoCard" ItemContainerStyle="{StaticResource StretchedItemContainerStyle}"
x:Load="{x:Bind ViewModel.CanShowBuyAiPack, Mode=OneWay}" ItemTemplateSelector="{StaticResource WinoAddOnTemplateSelector}"
Description="{x:Bind domain:Translator.WinoAccount_Management_AiPackPromoDescription}"> ItemsSource="{x:Bind ViewModel.AddOns, Mode=OneWay}"
<controls:SettingsCard.HeaderIcon> SelectionMode="None" />
<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">
<controls:SettingsExpander.HeaderIcon>
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE945;" />
</controls:SettingsExpander.HeaderIcon>
<controls:SettingsExpander.Header>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock FontWeight="SemiBold" Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackCardTitle}" />
<Border
Padding="8,2"
Background="{ThemeResource SystemAccentColor}"
CornerRadius="4">
<TextBlock
FontSize="10"
FontWeight="Bold"
Foreground="White"
Text="PRO" />
</Border>
</StackPanel>
</controls:SettingsExpander.Header>
<controls:SettingsExpander.Description>
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackSubscriptionActive}" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text=" · " />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.AiPackRenewalText, Mode=OneWay}" />
</StackPanel>
</controls:SettingsExpander.Description>
<HyperlinkButton Command="{x:Bind ViewModel.ManageAiPackCommand}" Content="{x:Bind domain:Translator.Buttons_Manage}" />
<controls:SettingsExpander.Items>
<!-- AI Pack active: usage bar -->
<controls:SettingsCard x:Name="AiPackUsageCard" 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}" />
<TextBlock
Margin="0,0,0,2"
VerticalAlignment="Bottom"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}">
<Run Text="/ " />
<Run Text="{x:Bind ViewModel.AiUsageLimit, Mode=OneWay}" />
</TextBlock>
</StackPanel>
<ProgressBar
Height="8"
Maximum="100"
Value="{x:Bind ViewModel.AiUsagePercentage, Mode=OneWay}" />
<Grid>
<TextBlock
HorizontalAlignment="Left"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_AiPackRequestsUsed}" />
<TextBlock
HorizontalAlignment="Right"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.AiUsageResetText, Mode=OneWay}" />
</Grid>
</StackPanel>
</controls:SettingsCard.Header>
</controls:SettingsCard>
</controls:SettingsExpander.Items>
</controls:SettingsExpander>
<!-- ─── Data section ─── -->
<TextBlock <TextBlock
Margin="0,12,0,4" Margin="0,12,0,4"
Style="{StaticResource BodyStrongTextBlockStyle}" Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind domain:Translator.WinoAccount_Management_DataSectionHeader}" /> 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> <controls:SettingsCard.HeaderIcon>
<FontIcon FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="&#xE896;" /> <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> </controls:SettingsCard.HeaderIcon>
<Button Command="{x:Bind ViewModel.ImportSettingsCommand}" Content="{x:Bind domain:Translator.Buttons_Browse}" /> <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> </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;" />
</controls:SettingsCard.HeaderIcon>
<Button Command="{x:Bind ViewModel.ExportSettingsCommand}" Content="{x:Bind domain:Translator.Buttons_Export}" />
</controls:SettingsCard>
<!-- ─── Account actions section ─── -->
<TextBlock <TextBlock
Margin="0,12,0,4" Margin="0,12,0,4"
Style="{StaticResource BodyStrongTextBlockStyle}" Style="{StaticResource BodyStrongTextBlockStyle}"
+1
View File
@@ -29,6 +29,7 @@ public static class ServicesContainerSetup
services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>(); services.AddTransient<IKeyboardShortcutService, KeyboardShortcutService>();
services.AddSingleton<IWinoAccountApiClient, WinoAccountApiClient>(); services.AddSingleton<IWinoAccountApiClient, WinoAccountApiClient>();
services.AddTransient<IWinoAccountProfileService, WinoAccountProfileService>(); services.AddTransient<IWinoAccountProfileService, WinoAccountProfileService>();
services.AddTransient<IWinoAddOnService, WinoAddOnService>();
services.AddSingleton<IContactPictureFileService, ContactPictureFileService>(); services.AddSingleton<IContactPictureFileService, ContactPictureFileService>();
services.AddTransient<ICalDavClient, CalDavClient>(); services.AddTransient<ICalDavClient, CalDavClient>();
+92 -12
View File
@@ -11,9 +11,12 @@ using System.Text.Json.Serialization.Metadata;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Billing;
using Wino.Mail.Api.Contracts.Common; using Wino.Mail.Api.Contracts.Common;
namespace Wino.Services; namespace Wino.Services;
@@ -46,13 +49,13 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
_ownsHttpClient = true; _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); => 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); => 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); => SendAuthRequestAsync("api/v1/auth/refresh", new RefreshRequest(refreshToken), WinoAccountApiJsonContext.Default.RefreshRequest, cancellationToken);
public async Task<ApiEnvelope<JsonElement>> LogoutAsync(string refreshToken, CancellationToken cancellationToken = default) 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) public Task<ApiEnvelope<AiStatusResultDto>> GetAiStatusAsync(CancellationToken cancellationToken = default)
=> SendAuthorizedRequestAsync("api/v1/ai/status", WinoAccountApiJsonContext.Default.ApiEnvelopeAiStatusResultDto, cancellationToken); => 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 var endpoint = productId switch
{ {
"ai-pack-monthly" => "api/v1/billing/ai-pack/checkout-session", WinoAddOnProductType.AI_PACK => "api/v1/billing/ai-pack/checkout-session",
"unlimited-accounts" => "api/v1/billing/unlimited-accounts/checkout-session", WinoAddOnProductType.UNLIMITED_ACCOUNTS => "api/v1/billing/unlimited-accounts/checkout-session",
_ => string.Empty _ => string.Empty
}; };
return string.IsNullOrWhiteSpace(endpoint) return string.IsNullOrWhiteSpace(endpoint)
? Task.FromResult(ApiEnvelope<string>.Failure("UnknownProduct")) ? Task.FromResult(ApiEnvelope<CheckoutSessionResultDto>.Failure("UnknownProduct"))
: SendAuthorizedRequestAsync(HttpMethod.Post, endpoint, WinoAccountApiJsonContext.Default.ApiEnvelopeString, cancellationToken); : 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) public async Task<string?> GetSettingsAsync(CancellationToken cancellationToken = default)
{ {
try 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 try
{ {
@@ -156,14 +166,83 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
? null ? null
: JsonSerializer.Deserialize(payload, WinoAccountApiJsonContext.Default.ApiEnvelopeAuthResultDto); : 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) 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) private Task<ApiEnvelope<TResponse>> SendAuthorizedRequestAsync<TResponse>(string endpoint, JsonTypeInfo<ApiEnvelope<TResponse>> typeInfo, CancellationToken cancellationToken)
=> SendAuthorizedRequestAsync(HttpMethod.Get, endpoint, typeInfo, cancellationToken); => SendAuthorizedRequestAsync(HttpMethod.Get, endpoint, typeInfo, cancellationToken);
@@ -234,6 +313,7 @@ public sealed class WinoAccountApiClient : IWinoAccountApiClient, IDisposable
[JsonSerializable(typeof(ApiEnvelope<AuthResultDto>))] [JsonSerializable(typeof(ApiEnvelope<AuthResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<AuthUserDto>))] [JsonSerializable(typeof(ApiEnvelope<AuthUserDto>))]
[JsonSerializable(typeof(ApiEnvelope<AiStatusResultDto>))] [JsonSerializable(typeof(ApiEnvelope<AiStatusResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<string>))] [JsonSerializable(typeof(ApiEnvelope<CheckoutSessionResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<CustomerPortalResultDto>))]
[JsonSerializable(typeof(ApiEnvelope<JsonElement>))] [JsonSerializable(typeof(ApiEnvelope<JsonElement>))]
internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext; internal sealed partial class WinoAccountApiJsonContext : JsonSerializerContext;
+82 -6
View File
@@ -1,13 +1,16 @@
#nullable enable #nullable enable
using System; using System;
using System.Reflection;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Serilog; using Serilog;
using Wino.Core.Domain.Entities.Shared; using Wino.Core.Domain.Entities.Shared;
using Wino.Core.Domain.Enums;
using Wino.Core.Domain.Interfaces; using Wino.Core.Domain.Interfaces;
using Wino.Core.Domain.Models.Accounts; using Wino.Core.Domain.Models.Accounts;
using Wino.Mail.Api.Contracts.Ai; using Wino.Mail.Api.Contracts.Ai;
using Wino.Mail.Api.Contracts.Auth; using Wino.Mail.Api.Contracts.Auth;
using Wino.Mail.Api.Contracts.Billing;
using Wino.Mail.Api.Contracts.Common; using Wino.Mail.Api.Contracts.Common;
using Wino.Messaging.UI; using Wino.Messaging.UI;
@@ -16,11 +19,15 @@ namespace Wino.Services;
public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccountProfileService public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccountProfileService
{ {
private readonly IWinoAccountApiClient _apiClient; private readonly IWinoAccountApiClient _apiClient;
private readonly IStoreManagementService _storeManagementService;
private readonly ILogger _logger = Log.ForContext<WinoAccountProfileService>(); 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; _apiClient = apiClient;
_storeManagementService = storeManagementService;
} }
public async Task<WinoAccountOperationResult> RegisterAsync(string email, string password, CancellationToken cancellationToken = default) 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() public async Task<bool> HasActiveAccountAsync()
=> await Connection.Table<WinoAccount>().CountAsync().ConfigureAwait(false) > 0; => 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) public async Task<ApiEnvelope<AuthUserDto>> GetCurrentUserAsync(CancellationToken cancellationToken = default)
{ {
var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false); var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
@@ -142,12 +159,12 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
return response; 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); var account = await GetAuthenticatedAccountAsync(cancellationToken).ConfigureAwait(false);
if (account == null) if (account == null)
{ {
return ApiEnvelope<string>.Failure("MissingAccessToken"); return ApiEnvelope<CheckoutSessionResultDto>.Failure("MissingAccessToken");
} }
var response = await _apiClient.CreateCheckoutSessionAsync(productId, cancellationToken).ConfigureAwait(false); var response = await _apiClient.CreateCheckoutSessionAsync(productId, cancellationToken).ConfigureAwait(false);
@@ -159,6 +176,23 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
return response; 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) public async Task SignOutAsync(CancellationToken cancellationToken = default)
{ {
var account = await GetActiveAccountAsync().ConfigureAwait(false); 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) if (!response.IsSuccess || response.Result == null)
{ {
_logger.Warning("Wino account operation failed. Error code: {ErrorCode}", response.ErrorCode); _logger.Warning("Wino account operation failed. Error code: {ErrorCode}. Error message: {ErrorMessage}", response.ErrorCode, response.ErrorMessage);
return WinoAccountOperationResult.Failure(response.ErrorCode); return WinoAccountOperationResult.Failure(response.ErrorCode, response.ErrorMessage);
} }
var account = Map(response.Result); var account = Map(response.Result);
@@ -203,6 +237,48 @@ public sealed class WinoAccountProfileService : BaseDatabaseService, IWinoAccoun
return WinoAccountOperationResult.Success(account); 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) private static WinoAccount Map(AuthResultDto result)
=> new() => 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))
];
}
}