diff --git "a/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-Fran\303\247ais.xcscheme" "b/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-Fran\303\247ais.xcscheme"
index f62c2999..98abcd95 100644
--- "a/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-Fran\303\247ais.xcscheme"
+++ "b/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-Fran\303\247ais.xcscheme"
@@ -88,7 +88,7 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
language = "fr"
- region = "GB"
+ region = "FR"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
@@ -106,7 +106,7 @@
diff --git a/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-Indonesian.xcscheme b/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-Indonesian.xcscheme
new file mode 100644
index 00000000..4b15534e
--- /dev/null
+++ b/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet-Indonesian.xcscheme
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet.xcscheme b/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet.xcscheme
index 150fa4b4..23c19487 100644
--- a/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet.xcscheme
+++ b/BrainwalletHybrid.xcworkspace/xcshareddata/xcschemes/brainwallet.xcscheme
@@ -106,7 +106,7 @@
referenceType = "1">
+ identifier = "../Private/general-purpose/BrainwalletiOSPrivateGeneralPurpose/brainwallet-private-general-purpose.storekit">
) {
+ init(shopBentoViewModel: ShopBentoViewModel,
+ newMViewModel: NewMainViewModel,
+ userPrefersDarkTheme: Binding) {
_userPrefersDarkTheme = userPrefersDarkTheme
- newMainViewModel = viewModel
+ shopViewModel = shopBentoViewModel
+ newMainViewModel = newMViewModel
}
+
+
var body: some View {
GeometryReader { geometry in
@@ -57,7 +65,7 @@ struct ShopBentoView: View {
}
.frame(width: width * 0.5)
VStack(alignment: .trailing) {
- GiftCardsView()
+ ShopCardsView(shopViewModel: shopViewModel)
}
.frame(maxWidth: width * 0.5)
}
diff --git a/brainwallet/Bento Views/Shop Bentos/ShopBentoViewModel.swift b/brainwallet/Bento Views/Shop Bentos/ShopBentoViewModel.swift
index bcb7c33e..fe484d2a 100644
--- a/brainwallet/Bento Views/Shop Bentos/ShopBentoViewModel.swift
+++ b/brainwallet/Bento Views/Shop Bentos/ShopBentoViewModel.swift
@@ -5,60 +5,7 @@
//// Created by Kerry Washington on 5/8/26.
//// Copyright © 2026 Grunt Software, LTD. All rights reserved.
////
-//import Foundation
-//import SwiftUI
-//
-//class ShopBentoViewModel: ObservableObject {
-//
-// // MARK: - Public Variables
-//
-// @Published
-// var widgetURL: URL = URL(string: BrainwalletShop.bitrefillCode)!
-//
-// init() {
-// fetchShopConfig()
-// }
-//
-// // MARK: - Models
-//
-// private struct ShopConfig: Decodable {
-// let shopData: [ShopItem]
-//
-// enum CodingKeys: String, CodingKey {
-// case shopData = "shop_data"
-// }
-// }
-//
-// private struct ShopItem: Decodable {
-// let widgetUrl: String
-//
-// enum CodingKeys: String, CodingKey {
-// case widgetUrl = "widget_url"
-// }
-// }
-//
-// func fetchShopConfig() {
-// let key = RemoteConfigKeys.PATH_SHOP_CONTENT.rawValue
-// let shopJson = RemoteConfigHelper().getString(key: key)
-//
-// guard
-// let data = shopJson.data(using: .utf8),
-// let config = try? JSONDecoder().decode(ShopConfig.self, from: data),
-// let firstItem = config.shopData.first,
-// let resolved = URL(string: firstItem.widgetUrl)
-// else { return }
-//
-// widgetURL = resolved
-// }
-//}
-//
-// ShopBentoViewModel.swift
-// brainwallet
-//
-// Created by Kerry Washington on 5/8/26.
-// Copyright © 2026 Grunt Software, LTD. All rights reserved.
-//
import Foundation
import SwiftUI
@@ -69,41 +16,71 @@ class ShopBentoViewModel: ObservableObject {
@Published
var widgetURL: URL = URL(string: BrainwalletShop.bitrefillCode)!
+ @Published
+ var cards: [ShopCard]?
+
+ @Published
+ var cardImages: [UIImage]?
+
+ @Published
+ var cardImagesVersion: Int = 0
+
+ @StateObject
+ private var loader = ImageLoader()
+
+
init() {
let key = RemoteConfigKeys.PATH_SHOP_CONTENT.rawValue
let shopJson = RemoteConfigHelper().getString(key: key)
fetchShopConfig(from: shopJson)
}
-
- // MARK: - Models
- private struct ShopConfig: Decodable {
- let shopData: [ShopItem]
-
- enum CodingKeys: String, CodingKey {
- case shopData = "shop_data"
- }
+ func updateCardImages(_ images: [UIImage]) {
+ cardImages = images
+ cardImagesVersion += 1
+
}
- private struct ShopItem: Decodable {
- let widgetUrl: String
-
- enum CodingKeys: String, CodingKey {
- case widgetUrl = "widget_url"
+ private func loadCardImages(_ cards: [ShopCard]?) async {
+ guard let realCards = cards else {
+ return
+ }
+ var loaded: [UIImage] = []
+ for card in realCards {
+ if let uiImage = await ImageLoader.loadImage(from: card.cardImageWebP) {
+ loaded.append(uiImage)
+ } else {
+ loaded.append(UIImage(named: "bw-placeholder")!)
+ }
+ }
+ await MainActor.run {
+ updateCardImages(loaded)
}
}
-
+
// MARK: - Fetch
- /// Internal entry point — accepts a raw JSON string so tests can inject directly.
func fetchShopConfig(from jsonString: String) {
- guard
- let data = jsonString.data(using: .utf8),
- let config = try? JSONDecoder().decode(ShopConfig.self, from: data),
- let firstItem = config.shopData.first,
- let resolved = URL(string: firstItem.widgetUrl)
- else { return }
+ guard let jsonData = jsonString.data(using: .utf8) else {
+ assertionFailure("ShopConfig: invalid UTF-8 string")
+ return
+ }
- widgetURL = resolved
+ do {
+ let shopConfig = try JSONDecoder().decode(ShopConfig.self, from: jsonData)
+ let data = shopConfig.shopData
+ widgetURL = data.widgetUrl
+
+ if let regionCode = Locale.current.region?.identifier {
+ cards = data.cards.filter { $0.countryCode.contains(regionCode) }
+ Task {
+ await loadCardImages(cards)
+ }
+ }
+
+ } catch {
+ assertionFailure("ShopConfig decode failed: \(error)")
+ }
}
}
+
diff --git a/brainwallet/Bento Views/Shop Bentos/GiftCardsView.swift b/brainwallet/Bento Views/Shop Bentos/ShopCardsView.swift
similarity index 59%
rename from brainwallet/Bento Views/Shop Bentos/GiftCardsView.swift
rename to brainwallet/Bento Views/Shop Bentos/ShopCardsView.swift
index 061361df..27e021e6 100644
--- a/brainwallet/Bento Views/Shop Bentos/GiftCardsView.swift
+++ b/brainwallet/Bento Views/Shop Bentos/ShopCardsView.swift
@@ -1,5 +1,5 @@
//
-// GiftCardsView.swift
+// ShopCardsView.swift
// brainwallet
//
// Created by Kerry Washington on 4/26/26.
@@ -9,23 +9,40 @@
import SwiftUI
-struct GiftCardsView: View {
+struct ShopCardsView: View {
@State
private var shouldAnimate: Bool = false
+ @State
+ private var image1 = UIImage(named: "bw-placeholder")!
+
+ @State
+ private var image2 = UIImage(named: "bw-placeholder")!
+
+ @State
+ private var image3 = UIImage(named: "bw-placeholder")!
+
+ @ObservedObject
+ var shopViewModel: ShopBentoViewModel
+
+ private func loadImages() {
+ let images = shopViewModel.cardImages
+ image1 = images?[safe: 0] ?? UIImage(named: "bw-placeholder")!
+ image2 = images?[safe: 1] ?? UIImage(named: "bw-placeholder")!
+ image3 = images?[safe: 2] ?? UIImage(named: "bw-placeholder")!
+ }
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
- let height = geometry.size.height
ZStack {
HStack {
VStack(alignment: .trailing) {
- SingleGiftCardView(giftCardImage: "visa_logo",
+ SingleShopCardView(cardUIImage: image1,
gradient: BrainwalletGradient.blueCard,
rotationAngle: 20.0)
.frame(width: 100, height: 63.05)
@@ -42,7 +59,7 @@ struct GiftCardsView: View {
HStack {
VStack(alignment: .trailing) {
- SingleGiftCardView(giftCardImage: "je_logo",
+ SingleShopCardView(cardUIImage: image2,
gradient: BrainwalletGradient.orangeCard,
rotationAngle: 20.0)
.frame(width: 100, height: 63.05)
@@ -61,8 +78,8 @@ struct GiftCardsView: View {
HStack {
VStack(alignment: .trailing) {
Spacer()
- SingleGiftCardView(giftCardImage: "amazon_logo",
- gradient: BrainwalletGradient.grayCard,
+ SingleShopCardView(cardUIImage: image3,
+ gradient: BrainwalletGradient.blueCard,
rotationAngle: -20.0)
.frame(width: 100, height: 63.05)
.offset(x: shouldAnimate ? 25 : 105, y: 15.0)
@@ -79,35 +96,42 @@ struct GiftCardsView: View {
}
}
+ .onChange(of: shopViewModel.cardImagesVersion) { _, newVersion in
+ loadImages()
+ withAnimation(.easeInOut(duration: 2.0)) {
+ shouldAnimate = true
+ }
+ }
}
}
-
-
-struct SingleGiftCardView: View {
-
- let giftCardImage: String
+struct SingleShopCardView: View {
+ let cardUIImage: UIImage
let gradient: RadialGradient
-
let rotationAngle: Double
-
var body: some View {
GeometryReader { geometry in
-
let width = geometry.size.width
ZStack {
- RoundedRectangle(cornerRadius: 8, style: .continuous)
- .fill(gradient)
- .frame(width: width, height: width * 0.6305)
- .rotationEffect(Angle(degrees: rotationAngle))
- Image(giftCardImage)
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(maxWidth: width * 0.6)
- .rotationEffect(Angle(degrees: rotationAngle))
- }
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(gradient)
+ .frame(width: width, height: width * 0.6305)
+ .rotationEffect(Angle(degrees: rotationAngle))
+
+ Image(uiImage: cardUIImage)
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: width, height: width * 0.6305)
+ .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
+ .rotationEffect(Angle(degrees: rotationAngle))
+ }
}
}
}
+extension Collection {
+ subscript(safe index: Index) -> Element? {
+ indices.contains(index) ? self[index] : nil
+ }
+}
diff --git a/brainwallet/Bento Views/Shop Bentos/ShopImages.swift b/brainwallet/Bento Views/Shop Bentos/ShopImages.swift
new file mode 100644
index 00000000..63781882
--- /dev/null
+++ b/brainwallet/Bento Views/Shop Bentos/ShopImages.swift
@@ -0,0 +1,85 @@
+//
+// ShopImages.swift
+// brainwallet
+//
+// Created by Kerry Washington on 5/23/26.
+// Copyright © 2026 Grunt Software, LTD. All rights reserved.
+//
+
+import SwiftUI
+import UIKit
+
+final class ImageLoader: ObservableObject {
+
+ private static let cache = NSCache()
+
+
+ static func loadImage(from url: URL) async -> UIImage? {
+
+ // Memory cache
+ if let cached = cache.object(forKey: url as NSURL) {
+ return cached
+ }
+
+ var request = URLRequest(url: url)
+
+ request.timeoutInterval = 20
+
+ // Important for CDNs
+ request.setValue(
+ "image/webp,image/*,*/*;q=0.8",
+ forHTTPHeaderField: "Accept"
+ )
+ request.setValue(
+ "https://www.bitrefill.com/",
+ forHTTPHeaderField: "Referer"
+ )
+ request.setValue(
+ "https://www.bitrefill.com",
+ forHTTPHeaderField: "Origin"
+ )
+
+ // Retry transient CDN failures
+ for attempt in 1...3 {
+
+ do {
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard
+ let http = response as? HTTPURLResponse
+ else {
+ continue
+ }
+
+ // Validate status code
+ guard (200...299).contains(http.statusCode) else {
+ let isRetryable = http.statusCode >= 500 || http.statusCode == 429
+ if isRetryable && attempt < 3 {
+ try await Task.sleep(for: .seconds(Double(attempt)))
+ continue
+ }
+ return nil
+ }
+
+ // Validate MIME type
+ guard (http.mimeType ?? "").starts(with: "image/") else {
+ return nil
+ }
+
+ // Validate image decode
+ guard let uiImage = UIImage(data: data) else {
+ return nil
+ }
+
+ cache.setObject(uiImage, forKey: url as NSURL)
+ return uiImage
+
+ } catch {
+ if attempt == 3 { return nil }
+ try? await Task.sleep(for: .seconds(Double(attempt) * 1.5))
+ }
+ }
+ return nil
+ }
+}
diff --git a/brainwallet/Bento Views/Shop Bentos/ShopStructs.swift b/brainwallet/Bento Views/Shop Bentos/ShopStructs.swift
new file mode 100644
index 00000000..9b6813b5
--- /dev/null
+++ b/brainwallet/Bento Views/Shop Bentos/ShopStructs.swift
@@ -0,0 +1,46 @@
+//
+// ShopStructs.swift
+// brainwallet
+//
+// Created by Kerry Washington on 5/23/26.
+// Copyright © 2026 Grunt Software, LTD. All rights reserved.
+//
+
+// MARK: - Shop Structs
+import SwiftUI
+
+struct ShopConfig: Codable {
+ let shopData: ShopData
+
+ enum CodingKeys: String, CodingKey {
+ case shopData = "shop_data"
+ }
+}
+
+struct ShopData: Codable {
+ let widgetUrl: URL
+ let cards: [ShopCard]
+ enum CodingKeys: String, CodingKey {
+ case widgetUrl = "widget_url"
+ case cards
+ }
+ }
+struct ShopCard: Codable, Identifiable {
+ let countryCode: String
+ let countryName: String
+ let productSlug: String
+ let productName: String
+ let productURL: URL
+ let cardImageWebP: URL
+
+ var id: String { productSlug }
+
+ enum CodingKeys: String, CodingKey {
+ case countryCode = "country_code"
+ case countryName = "country_name"
+ case productSlug = "product_slug"
+ case productName = "product_name"
+ case productURL = "product_url"
+ case cardImageWebP = "card_image_webp"
+ }
+}
diff --git a/brainwallet/New Main Classes/NewMainView.swift b/brainwallet/New Main Classes/NewMainView.swift
index 5d4bf877..2fc5a03c 100644
--- a/brainwallet/New Main Classes/NewMainView.swift
+++ b/brainwallet/New Main Classes/NewMainView.swift
@@ -23,7 +23,7 @@ struct NewMainView: View {
@StateObject
var gameHubViewModel = GameHubViewModel()
- @StateObject
+ @ObservedObject
var shopViewModel = ShopBentoViewModel()
@State
@@ -169,8 +169,9 @@ struct NewMainView: View {
.frame(maxHeight: heightPadded * 0.7)
.padding(bentoPadding)
.accessibilityIdentifier("ltcPriceBentoView")
- ShopBentoView(viewModel: newMainViewModel,
- userPrefersDarkTheme: $userPrefersDarkTheme)
+ ShopBentoView(shopBentoViewModel: shopViewModel,
+ newMViewModel: newMainViewModel,
+ userPrefersDarkTheme: $userPrefersDarkTheme)
.frame(maxHeight: heightPadded * 0.3)
.padding(bentoPadding)
.accessibilityIdentifier("shopBentoView")
diff --git a/brainwallet/PreLaunchResources/Assets.xcassets/Branding/bw-placeholder.imageset/Contents.json b/brainwallet/PreLaunchResources/Assets.xcassets/Branding/bw-placeholder.imageset/Contents.json
new file mode 100644
index 00000000..6db91b9d
--- /dev/null
+++ b/brainwallet/PreLaunchResources/Assets.xcassets/Branding/bw-placeholder.imageset/Contents.json
@@ -0,0 +1,21 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "bw-logo-padding.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/brainwallet/PreLaunchResources/Assets.xcassets/Branding/bw-placeholder.imageset/bw-logo-padding.png b/brainwallet/PreLaunchResources/Assets.xcassets/Branding/bw-placeholder.imageset/bw-logo-padding.png
new file mode 100644
index 00000000..485f8af4
Binary files /dev/null and b/brainwallet/PreLaunchResources/Assets.xcassets/Branding/bw-placeholder.imageset/bw-logo-padding.png differ