Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apple/TablePro/Core/Services/Licensing/SupportLinks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ internal enum SupportReferrer: Equatable {
case licenseSettings
case activationSheet
case ask
case blackFriday
case featureGate(ProFeature)

internal var parameterValue: String {
Expand All @@ -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)
}
Expand All @@ -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
Expand All @@ -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_<key>`, which it reports in the
/// sponsorship transaction export.
internal static func sponsors(_ referrer: SupportReferrer) -> URL {
Expand Down
33 changes: 33 additions & 0 deletions apple/TablePro/Core/Services/Support/DatedSale.swift
Original file line number Diff line number Diff line change
@@ -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)
)
}
10 changes: 10 additions & 0 deletions apple/TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {

Expand Down
20 changes: 20 additions & 0 deletions apple/TablePro/Views/Support/DatedSaleSchedule.swift
Original file line number Diff line number Diff line change
@@ -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]
}
}
25 changes: 24 additions & 1 deletion apple/TablePro/Views/Welcome/WelcomeActionsPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down Expand Up @@ -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")
Expand Down
36 changes: 36 additions & 0 deletions apple/TableProTests/Core/Services/Support/DatedSaleTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
44 changes: 44 additions & 0 deletions apple/TableProTests/Views/Support/DatedSaleScheduleTests.swift
Original file line number Diff line number Diff line change
@@ -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])
}
}
1 change: 1 addition & 0 deletions apple/changelog.d/3995.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
People without a license see a Black Friday offer on the Welcome window: 40% off Pro until December 1.
Loading