diff --git a/apple/TablePro/Core/Services/Licensing/SupportLinks.swift b/apple/TablePro/Core/Services/Licensing/SupportLinks.swift index d8729f13d0..51d6692db9 100644 --- a/apple/TablePro/Core/Services/Licensing/SupportLinks.swift +++ b/apple/TablePro/Core/Services/Licensing/SupportLinks.swift @@ -17,6 +17,7 @@ internal enum SupportReferrer: Equatable { case licenseSettings case activationSheet case ask + case blackFriday case featureGate(ProFeature) internal var parameterValue: String { @@ -31,6 +32,8 @@ internal enum SupportReferrer: Equatable { return "app-activation" case .ask: return "app-ask" + case .blackFriday: + return "app-black-friday" case .featureGate(let feature): return "app-gate-" + Self.slug(for: feature) } @@ -54,6 +57,8 @@ internal enum SupportLinks { /// reaches the server or `location.search` and nothing can read it. private static let pricingPage = URL(string: "https://tablepro.app/#pricing")! + private static let blackFridayPage = URL(string: "https://tablepro.app/black-friday")! + private static let sponsorsPage = URL(string: "https://github.com/sponsors/datlechin")! /// Where a licence is actually administered: invoices, seats, team members. A different place @@ -70,6 +75,11 @@ internal enum SupportLinks { pricingPage.appending(queryItems: [URLQueryItem(name: "ref", value: referrer.parameterValue)]) } + /// The sale page applies the discount code itself, so the link carries only the referrer. + internal static func blackFriday(_ referrer: SupportReferrer) -> URL { + blackFridayPage.appending(queryItems: [URLQueryItem(name: "ref", value: referrer.parameterValue)]) + } + /// GitHub Sponsors ignores `ref` and reads `metadata_`, which it reports in the /// sponsorship transaction export. internal static func sponsors(_ referrer: SupportReferrer) -> URL { diff --git a/apple/TablePro/Core/Services/Support/DatedSale.swift b/apple/TablePro/Core/Services/Support/DatedSale.swift new file mode 100644 index 0000000000..8a2905b60e --- /dev/null +++ b/apple/TablePro/Core/Services/Support/DatedSale.swift @@ -0,0 +1,33 @@ +// +// DatedSale.swift +// TablePro +// + +import Foundation + +/// A discount on Pro that runs between two fixed instants. +/// +/// Live from `start`, and gone at `end`. Kept pure, so its boundaries are tested without a window +/// or a clock. +internal struct DatedSale: Equatable, Sendable { + let start: Date + let end: Date + let percentOff: Int + let page: URL + + func isLive(at date: Date) -> Bool { + start <= date && date < end + } +} + +extension DatedSale { + /// The dates are fixed in the build because this is one dated sale, not a campaign the app + /// fetches: its notice is removed in the first release after it ends. The end is midnight + /// Pacific time on December 1. + static let blackFriday2026 = DatedSale( + start: Date(timeIntervalSince1970: 1_795_478_400), // 2026-11-24 00:00:00 UTC + end: Date(timeIntervalSince1970: 1_796_112_000), // 2026-12-01 08:00:00 UTC + percentOff: 40, + page: SupportLinks.blackFriday(.blackFriday) + ) +} diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index 18376062a4..68ff0662f7 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -31517,6 +31517,16 @@ }, "Binary values limit must be between 1 and 1,048,576 KB." : { + }, + "Black Friday: %lld%% off Pro until %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Black Friday: %1$lld%% off Pro until %2$@" + } + } + } }, "Bloated Tables" : { diff --git a/apple/TablePro/Views/Support/DatedSaleSchedule.swift b/apple/TablePro/Views/Support/DatedSaleSchedule.swift new file mode 100644 index 0000000000..79518ebdbd --- /dev/null +++ b/apple/TablePro/Views/Support/DatedSaleSchedule.swift @@ -0,0 +1,20 @@ +// +// DatedSaleSchedule.swift +// TablePro +// + +import SwiftUI + +/// Redraws a `TimelineView` when its sale starts and when it ends, and at no other time. +/// +/// Not `.explicit([sale.start, sale.end])`. Measured on macOS 27, that schedule draws its first +/// frame with its first entry as the date, so a notice would show before the sale opens, and it +/// never delivers its last entry, so the notice would stay after the sale ends. This one starts at +/// the moment the view appears and ends on a date that never comes, so every boundary arrives. +struct DatedSaleSchedule: TimelineSchedule { + let sale: DatedSale + + func entries(from startDate: Date, mode: TimelineScheduleMode) -> [Date] { + [startDate] + [sale.start, sale.end].filter { $0 > startDate } + [.distantFuture] + } +} diff --git a/apple/TablePro/Views/Welcome/WelcomeActionsPanel.swift b/apple/TablePro/Views/Welcome/WelcomeActionsPanel.swift index b85364c21b..3e26cd2b70 100644 --- a/apple/TablePro/Views/Welcome/WelcomeActionsPanel.swift +++ b/apple/TablePro/Views/Welcome/WelcomeActionsPanel.swift @@ -82,13 +82,36 @@ struct WelcomeActionsPanel: View { VStack(spacing: 4) { licenseNotice if licenseManager.supportAudience == .prospect { - SupportPromptLink() + prospectLinks } } .font(.subheadline) } } + // The timeline holds both links, not the sale line alone: an empty TimelineView still takes the + // stack's spacing, so outside the sale it would leave a gap under the support link. + private var prospectLinks: some View { + let sale = DatedSale.blackFriday2026 + return TimelineView(DatedSaleSchedule(sale: sale)) { context in + VStack(spacing: 4) { + SupportPromptLink() + if sale.isLive(at: context.date) { + Link( + String( + format: String(localized: "Black Friday: %lld%% off Pro until %@"), + sale.percentOff, + sale.end.formatted(.dateTime.month(.abbreviated).day()) + ), + destination: sale.page + ) + .buttonStyle(.link) + .accessibilityIdentifier("welcome-sale-link") + } + } + } + } + private var actions: some View { VStack(spacing: 8) { Button(action: onNewConnection) { diff --git a/apple/TableProTests/Core/Services/Licensing/SupportLinksTests.swift b/apple/TableProTests/Core/Services/Licensing/SupportLinksTests.swift index caf724177e..47fb817a84 100644 --- a/apple/TableProTests/Core/Services/Licensing/SupportLinksTests.swift +++ b/apple/TableProTests/Core/Services/Licensing/SupportLinksTests.swift @@ -12,7 +12,7 @@ import Testing struct SupportLinksTests { private static let everyReferrer: [SupportReferrer] = - [.supportWindow, .aboutPanel, .licenseSettings, .activationSheet, .ask] + [.supportWindow, .aboutPanel, .licenseSettings, .activationSheet, .ask, .blackFriday] + ProFeature.allCases.map { .featureGate($0) } private static func components(_ url: URL) -> URLComponents? { @@ -85,6 +85,17 @@ struct SupportLinksTests { } } + @Test("The Black Friday link opens the sale page with its ref and no code") + func blackFridayLinkIsTagged() throws { + let url = SupportLinks.blackFriday(.blackFriday) + let parts = try #require(Self.components(url)) + + #expect(url.absoluteString == "https://tablepro.app/black-friday?ref=app-black-friday") + #expect(parts.host == "tablepro.app") + #expect(parts.path == "/black-friday") + #expect(parts.queryItems == [URLQueryItem(name: "ref", value: "app-black-friday")]) + } + @Test("The Macs link opens the account page that removes a Mac from a license") func accountMachinesOpensTheMacsPage() { #expect(SupportLinks.accountMachines.absoluteString == "https://tablepro.app/account/machines") diff --git a/apple/TableProTests/Core/Services/Support/DatedSaleTests.swift b/apple/TableProTests/Core/Services/Support/DatedSaleTests.swift new file mode 100644 index 0000000000..9b4536a6ac --- /dev/null +++ b/apple/TableProTests/Core/Services/Support/DatedSaleTests.swift @@ -0,0 +1,36 @@ +// +// DatedSaleTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +struct DatedSaleTests { + private let sale = DatedSale.blackFriday2026 + + @Test("Black Friday runs from Nov 24 00:00 UTC to Dec 1 08:00 UTC") + func blackFridayInstants() throws { + #expect(sale.start == (try Date("2026-11-24T00:00:00Z", strategy: .iso8601))) + #expect(sale.end == (try Date("2026-12-01T08:00:00Z", strategy: .iso8601))) + #expect(sale.percentOff == 40) + } + + @Test("The sale opens at its start, not a second before") + func opensAtTheStart() { + #expect(!sale.isLive(at: sale.start.addingTimeInterval(-1))) + #expect(sale.isLive(at: sale.start)) + } + + @Test("The sale is still on a second before its end, and over at the end") + func closesAtTheEnd() { + #expect(sale.isLive(at: sale.end.addingTimeInterval(-1))) + #expect(!sale.isLive(at: sale.end)) + } + + @Test("The notice opens the sale page tagged with where it was clicked") + func pageCarriesItsReferrer() { + #expect(sale.page.absoluteString == "https://tablepro.app/black-friday?ref=app-black-friday") + } +} diff --git a/apple/TableProTests/Views/Support/DatedSaleScheduleTests.swift b/apple/TableProTests/Views/Support/DatedSaleScheduleTests.swift new file mode 100644 index 0000000000..9a784393b0 --- /dev/null +++ b/apple/TableProTests/Views/Support/DatedSaleScheduleTests.swift @@ -0,0 +1,44 @@ +// +// DatedSaleScheduleTests.swift +// TableProTests +// +// The first entry is the date a TimelineView draws its first frame with, and the last entry is +// never delivered, so each case checks both ends of the list. +// + +import Foundation +import SwiftUI +@testable import TablePro +import Testing + +struct DatedSaleScheduleTests { + private let sale = DatedSale.blackFriday2026 + + private var schedule: DatedSaleSchedule { + DatedSaleSchedule(sale: sale) + } + + @Test("Before the sale the view draws the present, then redraws at the start and the end") + func beforeTheSale() { + let now = sale.start.addingTimeInterval(-86_400) + + #expect(schedule.entries(from: now, mode: .normal) == [now, sale.start, sale.end, .distantFuture]) + } + + @Test("At the start the view draws the start, then redraws at the end") + func atTheStart() { + #expect(schedule.entries(from: sale.start, mode: .normal) == [sale.start, sale.end, .distantFuture]) + } + + @Test("During the sale only the end is still ahead") + func duringTheSale() { + let now = sale.end.addingTimeInterval(-1) + + #expect(schedule.entries(from: now, mode: .normal) == [now, sale.end, .distantFuture]) + } + + @Test("After the sale nothing is ahead") + func afterTheSale() { + #expect(schedule.entries(from: sale.end, mode: .normal) == [sale.end, .distantFuture]) + } +} diff --git a/apple/changelog.d/3995.added.md b/apple/changelog.d/3995.added.md new file mode 100644 index 0000000000..da0958696b --- /dev/null +++ b/apple/changelog.d/3995.added.md @@ -0,0 +1 @@ +People without a license see a Black Friday offer on the Welcome window: 40% off Pro until December 1.