| 1 | import SwiftUI |
| 2 | |
| 3 | // 🤖 The member's paid-access surface — the native twin of showMembership in |
| 4 | // web/static/shell.js. It lists the access levels a member can subscribe to, |
| 5 | // shows their current subscription and its exact state, and lets them change, |
| 6 | // cancel, or fix payment. Shared by iOS and Mac so the two can't drift. |
| 7 | // |
| 8 | // Design stance (matches the web): calm and never a dark pattern. The signature |
| 9 | // is the DATED PROMISE — every ending/lapsing state names the exact day access |
| 10 | // runs to ("Your access ends on 5 February 2026"), and cancelling is framed as |
| 11 | // "you keep it until then," not "you lose it now." State is carried by an icon |
| 12 | // AND words, never colour alone. One accent (the brand tint) marks the single |
| 13 | // loud action per card; everything else is quiet. |
| 14 | // |
| 15 | // Checkout and the billing portal are hosted by Stripe: those actions return a |
| 16 | // URL we open in the browser. Payment completes there; the entitlement (and the |
| 17 | // level key) arrive back via the webhook, so the member pulls to refresh — or |
| 18 | // reopens this sheet — to see the change. |
| 19 | public struct MembershipView: View { |
| 20 | @ObservedObject var store: ThreadStore |
| 21 | @Environment(\.openURL) private var openURL |
| 22 | |
| 23 | private enum Phase { |
| 24 | case loading |
| 25 | case loaded([TierRow]) |
| 26 | } |
| 27 | @State private var phase: Phase = .loading |
| 28 | @State private var working: String? // tier id mid-action (disables buttons) |
| 29 | @State private var status: String? // inline status line (never a toast) |
| 30 | @State private var cancelTarget: TierRow? // drives the dated cancel confirmation |
| 31 | @State private var changeFrom: TierRow? // drives the change-level sheet |
| 32 | |
| 33 | public init(store: ThreadStore) { self.store = store } |
| 34 | |
| 35 | public var body: some View { |
| 36 | ScrollView { |
| 37 | VStack(alignment: .leading, spacing: 16) { |
| 38 | switch phase { |
| 39 | case .loading: |
| 40 | HStack { Spacer(); ProgressView(); Spacer() }.padding(.top, 40) |
| 41 | case .loaded(let tiers): |
| 42 | content(tiers) |
| 43 | } |
| 44 | } |
| 45 | .padding() |
| 46 | .frame(maxWidth: 560, alignment: .leading) |
| 47 | .frame(maxWidth: .infinity) |
| 48 | } |
| 49 | .navigationTitle("Membership") |
| 50 | #if os(iOS) |
| 51 | .navigationBarTitleDisplayMode(.inline) |
| 52 | #endif |
| 53 | .task { await load() } |
| 54 | .refreshable { await load() } |
| 55 | .confirmationDialog(cancelPrompt, isPresented: cancelPresented, titleVisibility: .visible) { |
| 56 | if let t = cancelTarget { |
| 57 | Button("Cancel subscription", role: .destructive) { cancel(t) } |
| 58 | } |
| 59 | Button("Keep it", role: .cancel) {} |
| 60 | } |
| 61 | .sheet(item: $changeFrom) { from in |
| 62 | ChangeLevelSheet(from: from, others: sellable(currentTiers).filter { $0.id != from.id }) { to in |
| 63 | changeFrom = nil |
| 64 | change(from: from, to: to) |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | |
| 69 | // MARK: Content |
| 70 | |
| 71 | @ViewBuilder private func content(_ tiers: [TierRow]) -> some View { |
| 72 | let sellableTiers = sellable(tiers) |
| 73 | let subscribed = tiers.filter(\.subscribed) |
| 74 | let canSubscribe = sellableTiers.filter { !$0.subscribed } |
| 75 | |
| 76 | if let status { |
| 77 | Text(status) |
| 78 | .font(.footnote) |
| 79 | .foregroundStyle(.secondary) |
| 80 | .accessibilityAddTraits(.updatesFrequently) |
| 81 | } |
| 82 | |
| 83 | if sellableTiers.isEmpty && subscribed.isEmpty { |
| 84 | Text("This space doesn't offer paid access levels.") |
| 85 | .foregroundStyle(.secondary) |
| 86 | .padding(.top, 24) |
| 87 | } else { |
| 88 | Text("Subscriptions are handled securely by Stripe. You can change or cancel anytime.") |
| 89 | .font(.footnote) |
| 90 | .foregroundStyle(.secondary) |
| 91 | |
| 92 | // Your subscription(s) — current state, dated. |
| 93 | ForEach(subscribed) { planCard($0, isCurrent: true, others: sellableTiers) } |
| 94 | |
| 95 | // Levels available to subscribe to (not already subscribed). |
| 96 | if !canSubscribe.isEmpty { |
| 97 | if !subscribed.isEmpty { |
| 98 | Text("Other access levels") |
| 99 | .font(.footnote.weight(.semibold)) |
| 100 | .foregroundStyle(.secondary) |
| 101 | .textCase(.uppercase) |
| 102 | .padding(.top, 4) |
| 103 | } |
| 104 | ForEach(canSubscribe) { planCard($0, isCurrent: false, others: sellableTiers) } |
| 105 | } |
| 106 | } |
| 107 | } |
| 108 | |
| 109 | // A single access level as a card: name + price, a dated state line for a |
| 110 | // current subscription, and the right actions for that state. |
| 111 | @ViewBuilder private func planCard(_ t: TierRow, isCurrent: Bool, others: [TierRow]) -> some View { |
| 112 | VStack(alignment: .leading, spacing: 10) { |
| 113 | if isCurrent { |
| 114 | Text("Your plan") |
| 115 | .font(.caption2.weight(.semibold)) |
| 116 | .foregroundStyle(Color.accentColor) |
| 117 | .textCase(.uppercase) |
| 118 | .accessibilityHidden(true) |
| 119 | } |
| 120 | HStack(alignment: .firstTextBaseline) { |
| 121 | Text(t.name ?? "Access level") |
| 122 | .font(.headline) |
| 123 | Spacer(minLength: 8) |
| 124 | if let price = t.priceText { |
| 125 | (Text(price).font(.headline) |
| 126 | + Text(" / \(t.pricePer)").font(.subheadline).foregroundColor(.secondary)) |
| 127 | } |
| 128 | } |
| 129 | if isCurrent { stateLine(t) } |
| 130 | actions(t, others: others) |
| 131 | } |
| 132 | .padding(14) |
| 133 | .background( |
| 134 | RoundedRectangle(cornerRadius: 14, style: .continuous) |
| 135 | .fill(Color.secondaryBackground) |
| 136 | ) |
| 137 | .overlay( |
| 138 | RoundedRectangle(cornerRadius: 14, style: .continuous) |
| 139 | .strokeBorder(isCurrent ? Color.accentColor.opacity(0.35) : Color.clear, lineWidth: 1) |
| 140 | ) |
| 141 | } |
| 142 | |
| 143 | // The dated status sentence — icon (shape) AND words, never colour alone. |
| 144 | @ViewBuilder private func stateLine(_ t: TierRow) -> some View { |
| 145 | let status = t.status ?? "" |
| 146 | HStack(alignment: .firstTextBaseline, spacing: 6) { |
| 147 | switch status { |
| 148 | case "active": |
| 149 | Image(systemName: "checkmark.seal.fill").foregroundStyle(.secondary) |
| 150 | Text(t.paid_until != nil ? "Renews on \(dateStr(t.paid_until))" : "Active") |
| 151 | case "past_due": |
| 152 | Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) |
| 153 | Text("Payment didn't go through — access ends \(dateStr(t.grace_deadline)) unless it's fixed.") |
| 154 | default: // canceled / ending |
| 155 | Image(systemName: "calendar").foregroundStyle(.secondary) |
| 156 | Text("Your access ends on \(dateStr(t.grace_deadline ?? t.paid_until)).") |
| 157 | } |
| 158 | } |
| 159 | .font(.subheadline) |
| 160 | .accessibilityElement(children: .combine) |
| 161 | } |
| 162 | |
| 163 | // The action buttons appropriate to a level's state. Exactly one loud |
| 164 | // (accent) action per card; the rest are quiet. |
| 165 | @ViewBuilder private func actions(_ t: TierRow, others: [TierRow]) -> some View { |
| 166 | let busy = working != nil |
| 167 | let status = t.status ?? "" |
| 168 | HStack(spacing: 10) { |
| 169 | if !t.subscribed { |
| 170 | loud("Subscribe", busy: busy) { act(t.id) { await store.subscribeTier(t.id) } } |
| 171 | } else if status == "active" { |
| 172 | if others.contains(where: { $0.id != t.id }) { |
| 173 | quiet("Change level", busy: busy) { changeFrom = t } |
| 174 | } |
| 175 | quiet("Cancel", busy: busy) { cancelTarget = t } |
| 176 | quiet("Manage billing", busy: busy) { act(t.id) { await store.tierPortal(t.id) } } |
| 177 | } else if status == "past_due" { |
| 178 | loud("Update payment", busy: busy) { act(t.id) { await store.tierPortal(t.id) } } |
| 179 | quiet("Manage billing", busy: busy) { act(t.id) { await store.tierPortal(t.id) } } |
| 180 | } else { // ending — offer to resume (un-cancels the same sub, no new charge) |
| 181 | loud("Resume", busy: busy) { act(t.id) { await store.resumeTier(t.id) } } |
| 182 | quiet("Manage billing", busy: busy) { act(t.id) { await store.tierPortal(t.id) } } |
| 183 | } |
| 184 | Spacer(minLength: 0) |
| 185 | if working == t.id { ProgressView().controlSize(.small) } |
| 186 | } |
| 187 | .padding(.top, 2) |
| 188 | } |
| 189 | |
| 190 | private func loud(_ label: String, busy: Bool, _ tap: @escaping () -> Void) -> some View { |
| 191 | Button(label, action: tap) |
| 192 | .buttonStyle(.borderedProminent) |
| 193 | .buttonBorderShape(.capsule) |
| 194 | .controlSize(.small) |
| 195 | .disabled(busy) |
| 196 | } |
| 197 | |
| 198 | private func quiet(_ label: String, busy: Bool, _ tap: @escaping () -> Void) -> some View { |
| 199 | Button(label, action: tap) |
| 200 | .buttonStyle(.bordered) |
| 201 | .buttonBorderShape(.capsule) |
| 202 | .controlSize(.small) |
| 203 | .disabled(busy) |
| 204 | } |
| 205 | |
| 206 | // MARK: State + actions |
| 207 | |
| 208 | private var currentTiers: [TierRow] { |
| 209 | if case .loaded(let t) = phase { return t } |
| 210 | return [] |
| 211 | } |
| 212 | |
| 213 | private func sellable(_ tiers: [TierRow]) -> [TierRow] { tiers.filter(\.sellable) } |
| 214 | |
| 215 | private var cancelPresented: Binding<Bool> { |
| 216 | Binding(get: { cancelTarget != nil }, set: { if !$0 { cancelTarget = nil } }) |
| 217 | } |
| 218 | |
| 219 | // Dated promise, not a threat: name the exact day access continues to. |
| 220 | private var cancelPrompt: String { |
| 221 | guard let t = cancelTarget else { return "Cancel your subscription?" } |
| 222 | let until = dateStr(t.paid_until) |
| 223 | let name = t.name ?? "this level" |
| 224 | return until.isEmpty |
| 225 | ? "Cancel your \(name) subscription?" |
| 226 | : "You'll keep \(name) until \(until). Cancel your subscription?" |
| 227 | } |
| 228 | |
| 229 | private func load() async { |
| 230 | let tiers = await store.membershipTiers() |
| 231 | phase = .loaded(tiers) |
| 232 | } |
| 233 | |
| 234 | // Run a billing action, following a hosted URL when one comes back. |
| 235 | private func act(_ id: String, _ fn: @escaping () async -> LChatAPI.BillingResult) { |
| 236 | working = id |
| 237 | status = nil |
| 238 | Task { |
| 239 | let result = await fn() |
| 240 | working = nil |
| 241 | switch result { |
| 242 | case .url(let u): |
| 243 | openURL(u) |
| 244 | status = "Continue in your browser, then pull to refresh here." |
| 245 | case .ok: |
| 246 | status = "Done." |
| 247 | await load() |
| 248 | case .failed: |
| 249 | status = "Something went wrong — try again." |
| 250 | } |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | private func cancel(_ t: TierRow) { |
| 255 | working = t.id |
| 256 | status = "Cancelling…" |
| 257 | Task { |
| 258 | let result = await store.cancelTierSub(t.id) |
| 259 | working = nil |
| 260 | if case .ok = result { |
| 261 | status = "Cancelled — you keep access until it ends." |
| 262 | await load() |
| 263 | } else { |
| 264 | status = "Couldn't cancel — try again." |
| 265 | } |
| 266 | } |
| 267 | } |
| 268 | |
| 269 | private func change(from: TierRow, to: TierRow) { |
| 270 | working = from.id |
| 271 | status = "Switching…" |
| 272 | Task { |
| 273 | let result = await store.changeTier(from: from.id, to: to.id) |
| 274 | working = nil |
| 275 | if case .ok = result { |
| 276 | status = "Switched." |
| 277 | await load() |
| 278 | } else { |
| 279 | status = "Couldn't switch — try again." |
| 280 | } |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | private func dateStr(_ unix: Int64?) -> String { |
| 285 | guard let u = unix, u > 0 else { return "" } |
| 286 | return Date(timeIntervalSince1970: Double(u)) |
| 287 | .formatted(.dateTime.day().month(.wide).year()) |
| 288 | } |
| 289 | } |
| 290 | |
| 291 | // Change to another sellable level — a small picker sheet. Switching adjusts the |
| 292 | // next bill for the difference (Stripe prorates); confirmed on the parent. |
| 293 | private struct ChangeLevelSheet: View { |
| 294 | let from: TierRow |
| 295 | let others: [TierRow] |
| 296 | let onPick: (TierRow) -> Void |
| 297 | @Environment(\.dismiss) private var dismiss |
| 298 | @State private var confirming: TierRow? |
| 299 | |
| 300 | var body: some View { |
| 301 | NavigationStack { |
| 302 | List { |
| 303 | Section { |
| 304 | ForEach(others, id: \.id) { t in |
| 305 | Button { confirming = t } label: { |
| 306 | HStack { |
| 307 | Text(t.name ?? "Access level").foregroundStyle(.primary) |
| 308 | Spacer() |
| 309 | if let price = t.priceText { |
| 310 | Text("\(price) / \(t.pricePer)").foregroundStyle(.secondary) |
| 311 | } |
| 312 | } |
| 313 | } |
| 314 | } |
| 315 | } footer: { |
| 316 | Text("You're on \(from.name ?? "this level"). Switching adjusts your next bill for the difference.") |
| 317 | } |
| 318 | } |
| 319 | .navigationTitle("Change level") |
| 320 | #if os(iOS) |
| 321 | .navigationBarTitleDisplayMode(.inline) |
| 322 | #endif |
| 323 | .toolbar { |
| 324 | ToolbarItem(placement: .cancellationAction) { |
| 325 | Button("Cancel") { dismiss() } |
| 326 | } |
| 327 | } |
| 328 | .confirmationDialog( |
| 329 | confirming.map { "Switch to \($0.name ?? "this level")\($0.priceText.map { " (\($0) / \(confirming!.pricePer))" } ?? "")?" } ?? "", |
| 330 | isPresented: Binding(get: { confirming != nil }, set: { if !$0 { confirming = nil } }), |
| 331 | titleVisibility: .visible |
| 332 | ) { |
| 333 | if let t = confirming { |
| 334 | Button("Switch") { onPick(t) } |
| 335 | } |
| 336 | Button("Keep current", role: .cancel) {} |
| 337 | } |
| 338 | } |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | // A cross-platform "grouped card" fill — the native equivalent of the web's |
| 343 | // .plan-card background, adapting to light/dark. |
| 344 | extension Color { |
| 345 | static var secondaryBackground: Color { |
| 346 | #if os(iOS) |
| 347 | return Color(uiColor: .secondarySystemBackground) |
| 348 | #else |
| 349 | return Color(nsColor: .underPageBackgroundColor) |
| 350 | #endif |
| 351 | } |
| 352 | } |
| 353 | |