eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import 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.
19public 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.
293private 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.
344extension 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