|
|
@@ -398,6 +398,8 @@ final class ViewController: NSViewController {
|
|
|
private var paywallPurchaseTask: Task<Void, Never>?
|
|
|
private var paywallPriceLabels: [PremiumPlan: NSTextField] = [:]
|
|
|
private var paywallSubtitleLabels: [PremiumPlan: NSTextField] = [:]
|
|
|
+ private var paywallBadgeLabels: [PremiumPlan: NSTextField] = [:]
|
|
|
+ private var paywallStrikePriceLabels: [PremiumPlan: NSTextField] = [:]
|
|
|
private var paywallContinueEnabled = true
|
|
|
private var paywallUpgradeFlowEnabled = false
|
|
|
private let launchPaywallDelay: TimeInterval = 3
|
|
|
@@ -1329,6 +1331,8 @@ private extension ViewController {
|
|
|
paywallFooterActionByView.removeAll()
|
|
|
paywallPriceLabels.removeAll()
|
|
|
paywallSubtitleLabels.removeAll()
|
|
|
+ paywallBadgeLabels.removeAll()
|
|
|
+ paywallStrikePriceLabels.removeAll()
|
|
|
paywallContinueLabel = nil
|
|
|
paywallContinueButton = nil
|
|
|
paywallContinueEnabled = true
|
|
|
@@ -2388,39 +2392,100 @@ private extension ViewController {
|
|
|
}
|
|
|
let productID = PremiumStoreProduct.productID(for: plan)
|
|
|
if let product = storeKitCoordinator.productsByID[productID] {
|
|
|
- let pkrPrice = pkrDisplayPrice(product.displayPrice)
|
|
|
+ let localizedPrice = localizedDisplayPrice(product.displayPrice)
|
|
|
if product.type == .nonConsumable {
|
|
|
- return "\(pkrPrice) one-time purchase"
|
|
|
+ return "\(localizedPrice) one-time purchase"
|
|
|
}
|
|
|
if let subscription = product.subscription {
|
|
|
- let billingText = "\(pkrPrice)/\(subscriptionUnitText(subscription.subscriptionPeriod.unit))"
|
|
|
+ let billingText = "\(localizedPrice)/\(subscriptionUnitText(subscription.subscriptionPeriod.unit))"
|
|
|
if let introOffer = subscription.introductoryOffer,
|
|
|
introOffer.paymentMode == .freeTrial {
|
|
|
return "Free for \(subscriptionPeriodText(introOffer.period)), then \(billingText)"
|
|
|
}
|
|
|
return billingText
|
|
|
}
|
|
|
- return pkrPrice
|
|
|
+ return localizedPrice
|
|
|
}
|
|
|
+ let fallbackPrice = fallbackPriceText(for: plan)
|
|
|
switch plan {
|
|
|
case .weekly:
|
|
|
- return "PKR 1,100.00/week"
|
|
|
+ return "\(fallbackPrice)/week"
|
|
|
case .monthly:
|
|
|
- return "PKR 2,500.00/month"
|
|
|
+ return "\(fallbackPrice)/month"
|
|
|
case .yearly:
|
|
|
- return "PKR 9,900.00/year (3 days free trial)"
|
|
|
+ return "\(fallbackPrice)/year (3 days free trial)"
|
|
|
case .lifetime:
|
|
|
- return "PKR 14,900.00 one-time purchase"
|
|
|
+ return "\(fallbackPrice) one-time purchase"
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private func pkrDisplayPrice(_ value: String) -> String {
|
|
|
- if value.hasPrefix("PKR ") { return value }
|
|
|
- if value.hasPrefix("Rs ") {
|
|
|
- return "PKR " + value.dropFirst(3)
|
|
|
+ private func localizedDisplayPrice(_ value: String) -> String {
|
|
|
+ value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func fallbackPriceText(for plan: PremiumPlan) -> String {
|
|
|
+ let fallbackAmount: Decimal
|
|
|
+ switch plan {
|
|
|
+ case .weekly:
|
|
|
+ fallbackAmount = 1100
|
|
|
+ case .monthly:
|
|
|
+ fallbackAmount = 2500
|
|
|
+ case .yearly:
|
|
|
+ fallbackAmount = 9900
|
|
|
+ case .lifetime:
|
|
|
+ fallbackAmount = 14900
|
|
|
+ }
|
|
|
+ return localeCurrencyText(for: fallbackAmount)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func fallbackLifetimeStrikePriceText() -> String {
|
|
|
+ localeCurrencyText(for: 29_800)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func fallbackAmount(for plan: PremiumPlan) -> Decimal {
|
|
|
+ switch plan {
|
|
|
+ case .weekly:
|
|
|
+ return 1100
|
|
|
+ case .monthly:
|
|
|
+ return 2500
|
|
|
+ case .yearly:
|
|
|
+ return 9900
|
|
|
+ case .lifetime:
|
|
|
+ return 14900
|
|
|
}
|
|
|
- if value.contains("PKR") { return value }
|
|
|
- return "PKR \(value)"
|
|
|
+ }
|
|
|
+
|
|
|
+ private func yearlySavingsBadgeText() -> String {
|
|
|
+ let yearlyProduct = storeKitCoordinator.productsByID[PremiumStoreProduct.yearly]
|
|
|
+ let monthlyProduct = storeKitCoordinator.productsByID[PremiumStoreProduct.monthly]
|
|
|
+
|
|
|
+ let yearlyAmount = yearlyProduct?.price ?? fallbackAmount(for: .yearly)
|
|
|
+ let monthlyAmount = monthlyProduct?.price ?? fallbackAmount(for: .monthly)
|
|
|
+ let yearlyCostIfMonthly = monthlyAmount * Decimal(12)
|
|
|
+ guard yearlyCostIfMonthly > 0 else { return "Save 0%" }
|
|
|
+
|
|
|
+ let savingsRatio = (yearlyCostIfMonthly - yearlyAmount) / yearlyCostIfMonthly
|
|
|
+ guard savingsRatio > 0 else { return "Save 0%" }
|
|
|
+ let savingsPercentDecimal = savingsRatio * Decimal(100)
|
|
|
+ let roundedPercent = Int(NSDecimalNumber(decimal: savingsPercentDecimal).doubleValue.rounded())
|
|
|
+ let savingsPercent = max(1, roundedPercent)
|
|
|
+ return "Save \(savingsPercent)%"
|
|
|
+ }
|
|
|
+
|
|
|
+ private func localeCurrencyText(for amount: Decimal) -> String {
|
|
|
+ let formatter = NumberFormatter()
|
|
|
+ formatter.numberStyle = .currency
|
|
|
+ formatter.locale = Locale.current
|
|
|
+
|
|
|
+ if formatter.currencyCode == nil {
|
|
|
+ formatter.currencyCode = "USD"
|
|
|
+ }
|
|
|
+ return formatter.string(from: amount as NSDecimalNumber) ?? "\(amount)"
|
|
|
+ }
|
|
|
+
|
|
|
+ private func strikePriceText(for product: Product) -> String {
|
|
|
+ let strikeAmount = product.price * Decimal(2)
|
|
|
+ return strikeAmount.formatted(product.priceFormatStyle)
|
|
|
}
|
|
|
|
|
|
private func subscriptionUnitText(_ unit: Product.SubscriptionPeriod.Unit) -> String {
|
|
|
@@ -2465,7 +2530,9 @@ private extension ViewController {
|
|
|
for (plan, label) in paywallPriceLabels {
|
|
|
let productID = PremiumStoreProduct.productID(for: plan)
|
|
|
if let product = storeKitCoordinator.productsByID[productID] {
|
|
|
- label.stringValue = pkrDisplayPrice(product.displayPrice)
|
|
|
+ label.stringValue = localizedDisplayPrice(product.displayPrice)
|
|
|
+ } else {
|
|
|
+ label.stringValue = fallbackPriceText(for: plan)
|
|
|
}
|
|
|
}
|
|
|
for (plan, label) in paywallSubtitleLabels {
|
|
|
@@ -2476,13 +2543,24 @@ private extension ViewController {
|
|
|
label.stringValue = "Billed via App Store"
|
|
|
continue
|
|
|
}
|
|
|
- let recurringText = "\(pkrDisplayPrice(product.displayPrice))/\(subscriptionUnitText(period.unit))"
|
|
|
+ let recurringText = "\(localizedDisplayPrice(product.displayPrice))/\(subscriptionUnitText(period.unit))"
|
|
|
if let trialText = freeTrialPackageText(for: product) {
|
|
|
label.stringValue = "\(recurringText) • \(trialText)"
|
|
|
} else {
|
|
|
label.stringValue = recurringText
|
|
|
}
|
|
|
}
|
|
|
+ for (plan, label) in paywallStrikePriceLabels {
|
|
|
+ guard plan == .lifetime else { continue }
|
|
|
+ let productID = PremiumStoreProduct.productID(for: plan)
|
|
|
+ if let product = storeKitCoordinator.productsByID[productID] {
|
|
|
+ label.stringValue = strikePriceText(for: product)
|
|
|
+ } else {
|
|
|
+ label.stringValue = fallbackLifetimeStrikePriceText()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ paywallBadgeLabels[.yearly]?.stringValue = yearlySavingsBadgeText()
|
|
|
+ paywallBadgeLabels[.lifetime]?.stringValue = "Best Value"
|
|
|
refreshSidebarPremiumButton()
|
|
|
refreshInstantMeetPremiumState()
|
|
|
updatePaywallPlanSelection()
|
|
|
@@ -5735,6 +5813,10 @@ private extension ViewController {
|
|
|
func makePaywallContent() -> NSView {
|
|
|
paywallPlanViews.removeAll()
|
|
|
premiumPlanByView.removeAll()
|
|
|
+ paywallPriceLabels.removeAll()
|
|
|
+ paywallSubtitleLabels.removeAll()
|
|
|
+ paywallBadgeLabels.removeAll()
|
|
|
+ paywallStrikePriceLabels.removeAll()
|
|
|
|
|
|
let panel = NSView()
|
|
|
panel.translatesAutoresizingMaskIntoConstraints = false
|
|
|
@@ -5834,7 +5916,7 @@ private extension ViewController {
|
|
|
|
|
|
let weeklyCard = paywallPlanCard(
|
|
|
title: "Weekly",
|
|
|
- price: "PKR 1,100.00",
|
|
|
+ price: fallbackPriceText(for: .weekly),
|
|
|
badge: "Basic",
|
|
|
badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
subtitle: nil,
|
|
|
@@ -5843,7 +5925,7 @@ private extension ViewController {
|
|
|
)
|
|
|
let monthlyCard = paywallPlanCard(
|
|
|
title: "Monthly",
|
|
|
- price: "PKR 2,500.00",
|
|
|
+ price: fallbackPriceText(for: .monthly),
|
|
|
badge: "Popular",
|
|
|
badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
|
|
|
subtitle: nil,
|
|
|
@@ -5852,8 +5934,8 @@ private extension ViewController {
|
|
|
)
|
|
|
let yearlyCard = paywallPlanCard(
|
|
|
title: "Yearly",
|
|
|
- price: "PKR 9,900.00",
|
|
|
- badge: "Best Value",
|
|
|
+ price: fallbackPriceText(for: .yearly),
|
|
|
+ badge: yearlySavingsBadgeText(),
|
|
|
badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
subtitle: "3 days free trial",
|
|
|
plan: .yearly,
|
|
|
@@ -5861,12 +5943,12 @@ private extension ViewController {
|
|
|
)
|
|
|
let lifetimeCard = paywallPlanCard(
|
|
|
title: "Lifetime",
|
|
|
- price: "PKR 14,900.00",
|
|
|
- badge: "Save 50%",
|
|
|
+ price: fallbackPriceText(for: .lifetime),
|
|
|
+ badge: "Best Value",
|
|
|
badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
|
|
|
subtitle: nil,
|
|
|
plan: .lifetime,
|
|
|
- strikePrice: "PKR 29,800.00"
|
|
|
+ strikePrice: nil
|
|
|
)
|
|
|
plansRow.addArrangedSubview(weeklyCard)
|
|
|
plansRow.addArrangedSubview(monthlyCard)
|
|
|
@@ -5997,6 +6079,7 @@ private extension ViewController {
|
|
|
styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
|
|
|
|
|
|
let badgeLabel = textLabel(badge, font: NSFont.systemFont(ofSize: 10, weight: .bold), color: .white)
|
|
|
+ paywallBadgeLabels[plan] = badgeLabel
|
|
|
let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
|
|
|
badgeWrap.translatesAutoresizingMaskIntoConstraints = false
|
|
|
badgeWrap.wantsLayer = true
|
|
|
@@ -6047,6 +6130,7 @@ private extension ViewController {
|
|
|
if let strikePrice {
|
|
|
let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
|
|
|
card.addSubview(strike)
|
|
|
+ paywallStrikePriceLabels[plan] = strike
|
|
|
NSLayoutConstraint.activate([
|
|
|
strike.leadingAnchor.constraint(equalTo: priceLabel.leadingAnchor),
|
|
|
strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4),
|