Route all affiliate links through voltplan.app
Replace client-side Amazon affiliate resolution with server-side
redirects via voltplan.app/{componentId} and voltplan.app/search?q=.
Remove AmazonAffiliate, affiliate link fetching, and per-model
affiliate fields in favor of a single componentID.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,77 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
enum AmazonAffiliate {
|
||||
private static let fallbackDomain = "www.amazon.com"
|
||||
private static let fallbackTag: String? = "voltplan-20"
|
||||
enum VoltPlanRedirect {
|
||||
private static let baseURL = "https://voltplan.app"
|
||||
|
||||
private static let domainsByCountry: [String: String] = [
|
||||
"US": "www.amazon.com",
|
||||
"DE": "www.amazon.de",
|
||||
"FR": "www.amazon.fr",
|
||||
"ES": "www.amazon.es",
|
||||
"IT": "www.amazon.it",
|
||||
"GB": "www.amazon.co.uk",
|
||||
"CA": "www.amazon.ca",
|
||||
"JP": "www.amazon.co.jp",
|
||||
"AU": "www.amazon.com.au",
|
||||
"NL": "www.amazon.nl",
|
||||
"SE": "www.amazon.se",
|
||||
"PL": "www.amazon.pl",
|
||||
"MX": "www.amazon.com.mx",
|
||||
"BR": "www.amazon.com.br",
|
||||
"IN": "www.amazon.in"
|
||||
]
|
||||
static func componentURL(id: String) -> URL? {
|
||||
var components = URLComponents(string: "\(baseURL)/\(id)")
|
||||
components?.queryItems = [URLQueryItem(name: "src", value: "cable")]
|
||||
return components?.url
|
||||
}
|
||||
|
||||
// Configure Amazon affiliate tracking IDs by country code.
|
||||
private static let tagsByCountry: [String: String] = [
|
||||
"US": "voltplan-20",
|
||||
"DE": "voltplan-21",
|
||||
"AU": "voltplan-22",
|
||||
"GB": "voltplan00-21",
|
||||
"FR": "voltplan0f-21",
|
||||
"CA": "voltplan01-20"
|
||||
]
|
||||
|
||||
private static let countryAliases: [String: String] = [
|
||||
"UK": "GB"
|
||||
]
|
||||
|
||||
static func searchURL(query: String, countryCode: String?) -> URL? {
|
||||
static func searchURL(query: String) -> URL? {
|
||||
guard !query.isEmpty else { return nil }
|
||||
|
||||
var components = URLComponents()
|
||||
components.scheme = "https"
|
||||
components.host = domain(for: countryCode)
|
||||
components.path = "/s"
|
||||
|
||||
var queryItems = [URLQueryItem(name: "k", value: query)]
|
||||
if let tag = affiliateTag(for: countryCode), !tag.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "tag", value: tag))
|
||||
}
|
||||
components.queryItems = queryItems
|
||||
|
||||
return components.url
|
||||
}
|
||||
|
||||
static func domain(for countryCode: String?) -> String {
|
||||
guard let normalized = normalizedCountryCode(from: countryCode) else {
|
||||
return fallbackDomain
|
||||
}
|
||||
return domainsByCountry[normalized] ?? fallbackDomain
|
||||
}
|
||||
|
||||
static func affiliateTag(for countryCode: String?) -> String? {
|
||||
guard let normalized = normalizedCountryCode(from: countryCode) else {
|
||||
return fallbackTag
|
||||
}
|
||||
return tagsByCountry[normalized] ?? fallbackTag
|
||||
}
|
||||
|
||||
private static func normalizedCountryCode(from countryCode: String?) -> String? {
|
||||
guard let raw = countryCode?.uppercased(), !raw.isEmpty else { return nil }
|
||||
if let alias = countryAliases[raw] {
|
||||
return alias
|
||||
}
|
||||
return raw
|
||||
var components = URLComponents(string: "\(baseURL)/search")
|
||||
components?.queryItems = [
|
||||
URLQueryItem(name: "q", value: query),
|
||||
URLQueryItem(name: "src", value: "cable"),
|
||||
]
|
||||
return components?.url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,8 +16,7 @@ class SavedBattery {
|
||||
var iconName: String = "battery.100"
|
||||
var colorName: String = "blue"
|
||||
var system: ElectricalSystem?
|
||||
var affiliateURLString: String?
|
||||
var affiliateCountryCode: String?
|
||||
var componentID: String?
|
||||
var bomCompletedItemIDs: [String] = []
|
||||
var timestamp: Date
|
||||
|
||||
@@ -35,8 +34,7 @@ class SavedBattery {
|
||||
iconName: String = "battery.100",
|
||||
colorName: String = "blue",
|
||||
system: ElectricalSystem? = nil,
|
||||
affiliateURLString: String? = nil,
|
||||
affiliateCountryCode: String? = nil,
|
||||
componentID: String? = nil,
|
||||
bomCompletedItemIDs: [String] = [],
|
||||
timestamp: Date = Date()
|
||||
) {
|
||||
@@ -53,8 +51,7 @@ class SavedBattery {
|
||||
self.iconName = iconName
|
||||
self.colorName = colorName
|
||||
self.system = system
|
||||
self.affiliateURLString = affiliateURLString
|
||||
self.affiliateCountryCode = affiliateCountryCode
|
||||
self.componentID = componentID
|
||||
self.bomCompletedItemIDs = bomCompletedItemIDs
|
||||
self.timestamp = timestamp
|
||||
}
|
||||
|
||||
@@ -14,8 +14,7 @@ final class SavedCharger {
|
||||
var system: ElectricalSystem?
|
||||
var timestamp: Date
|
||||
var remoteIconURLString: String?
|
||||
var affiliateURLString: String?
|
||||
var affiliateCountryCode: String?
|
||||
var componentID: String?
|
||||
var bomCompletedItemIDs: [String] = []
|
||||
var identifier: String
|
||||
var powerSourceType: String = "shore"
|
||||
@@ -67,8 +66,7 @@ final class SavedCharger {
|
||||
system: ElectricalSystem? = nil,
|
||||
timestamp: Date = Date(),
|
||||
remoteIconURLString: String? = nil,
|
||||
affiliateURLString: String? = nil,
|
||||
affiliateCountryCode: String? = nil,
|
||||
componentID: String? = nil,
|
||||
bomCompletedItemIDs: [String] = [],
|
||||
identifier: String = UUID().uuidString,
|
||||
powerSourceType: String = "shore"
|
||||
@@ -84,8 +82,7 @@ final class SavedCharger {
|
||||
self.system = system
|
||||
self.timestamp = timestamp
|
||||
self.remoteIconURLString = remoteIconURLString
|
||||
self.affiliateURLString = affiliateURLString
|
||||
self.affiliateCountryCode = affiliateCountryCode
|
||||
self.componentID = componentID
|
||||
self.bomCompletedItemIDs = bomCompletedItemIDs
|
||||
self.identifier = identifier
|
||||
self.powerSourceType = powerSourceType
|
||||
|
||||
@@ -127,8 +127,7 @@ class SavedLoad {
|
||||
var dailyUsageHours: Double = 24.0
|
||||
var system: ElectricalSystem?
|
||||
var remoteIconURLString: String? = nil
|
||||
var affiliateURLString: String? = nil
|
||||
var affiliateCountryCode: String? = nil
|
||||
var componentID: String? = nil
|
||||
var bomCompletedItemIDs: [String] = []
|
||||
var identifier: String = UUID().uuidString
|
||||
|
||||
@@ -146,8 +145,7 @@ class SavedLoad {
|
||||
dailyUsageHours: Double = 24.0,
|
||||
system: ElectricalSystem? = nil,
|
||||
remoteIconURLString: String? = nil,
|
||||
affiliateURLString: String? = nil,
|
||||
affiliateCountryCode: String? = nil,
|
||||
componentID: String? = nil,
|
||||
bomCompletedItemIDs: [String] = [],
|
||||
identifier: String = UUID().uuidString
|
||||
) {
|
||||
@@ -165,8 +163,7 @@ class SavedLoad {
|
||||
self.dailyUsageHours = dailyUsageHours
|
||||
self.system = system
|
||||
self.remoteIconURLString = remoteIconURLString
|
||||
self.affiliateURLString = affiliateURLString
|
||||
self.affiliateCountryCode = affiliateCountryCode
|
||||
self.componentID = componentID
|
||||
self.bomCompletedItemIDs = bomCompletedItemIDs
|
||||
self.identifier = identifier
|
||||
}
|
||||
|
||||
@@ -51,16 +51,14 @@ struct CalculatorView: View {
|
||||
|
||||
struct AffiliateLinkInfo: Identifiable, Equatable {
|
||||
let id: String
|
||||
let affiliateURL: URL?
|
||||
let componentID: String?
|
||||
let buttonTitle: String
|
||||
let regionName: String?
|
||||
let countryCode: String?
|
||||
}
|
||||
|
||||
struct BOMItem: Identifiable, Equatable {
|
||||
enum Destination: Equatable {
|
||||
case affiliate(URL)
|
||||
case amazonSearch(String)
|
||||
case component(String)
|
||||
case search(String)
|
||||
}
|
||||
|
||||
let id: String
|
||||
@@ -391,7 +389,7 @@ struct CalculatorView: View {
|
||||
private func billOfMaterialsSheet(info: AffiliateLinkInfo) -> some View {
|
||||
BillOfMaterialsView(
|
||||
info: info,
|
||||
items: buildBillOfMaterialsItems(deviceLink: info.affiliateURL),
|
||||
items: buildBillOfMaterialsItems(componentID: info.componentID),
|
||||
completedItemIDs: $completedItemIDs
|
||||
)
|
||||
}
|
||||
@@ -467,18 +465,6 @@ struct CalculatorView: View {
|
||||
}
|
||||
|
||||
private var affiliateLinkInfo: AffiliateLinkInfo {
|
||||
let affiliateURL: URL?
|
||||
if let urlString = savedLoad?.affiliateURLString,
|
||||
let parsedURL = URL(string: urlString) {
|
||||
affiliateURL = parsedURL
|
||||
} else {
|
||||
affiliateURL = nil
|
||||
}
|
||||
|
||||
let rawCountryCode = savedLoad?.affiliateCountryCode ?? Locale.current.region?.identifier
|
||||
let countryCode = rawCountryCode?.uppercased()
|
||||
let regionName = countryCode.flatMap { Locale.current.localizedString(forRegionCode: $0) ?? $0 }
|
||||
|
||||
let buttonTitle = String(localized: "affiliate.button.review_parts", comment: "Button title to review a bill of materials before shopping")
|
||||
let name = savedLoad?.name ?? calculator.loadName
|
||||
let timestamp = savedLoad?.timestamp ?? Date(timeIntervalSince1970: 0)
|
||||
@@ -486,10 +472,8 @@ struct CalculatorView: View {
|
||||
|
||||
return AffiliateLinkInfo(
|
||||
id: identifier,
|
||||
affiliateURL: affiliateURL,
|
||||
buttonTitle: buttonTitle,
|
||||
regionName: regionName,
|
||||
countryCode: countryCode
|
||||
componentID: savedLoad?.componentID,
|
||||
buttonTitle: buttonTitle
|
||||
)
|
||||
}
|
||||
|
||||
@@ -756,7 +740,7 @@ struct CalculatorView: View {
|
||||
Button {
|
||||
AnalyticsTracker.log("Review Parts Tapped", properties: [
|
||||
"load": info.id,
|
||||
"has_affiliate": info.affiliateURL != nil,
|
||||
"has_component": info.componentID != nil,
|
||||
])
|
||||
presentedAffiliateLink = info
|
||||
} label: {
|
||||
@@ -773,7 +757,7 @@ struct CalculatorView: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
let description = info.affiliateURL != nil
|
||||
let description = info.componentID != nil
|
||||
? String(localized: "affiliate.description.with_link", defaultValue: "Tapping above shows a full bill of materials before opening the affiliate link. Purchases may support VoltPlan.")
|
||||
: String(localized: "affiliate.description.without_link", defaultValue: "Tapping above shows a full bill of materials with shopping searches to help you source parts.")
|
||||
Text(description)
|
||||
@@ -785,7 +769,7 @@ struct CalculatorView: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||
}
|
||||
|
||||
private func buildBillOfMaterialsItems(deviceLink: URL?) -> [BOMItem] {
|
||||
private func buildBillOfMaterialsItems(componentID: String?) -> [BOMItem] {
|
||||
let unitSystem = unitSettings.unitSystem
|
||||
let lengthValue = calculator.length
|
||||
let lengthLabel = String(format: "%.1f %@", lengthValue, unitSystem.lengthUnit)
|
||||
@@ -847,7 +831,7 @@ struct CalculatorView: View {
|
||||
title: calculator.loadName.isEmpty ? fallbackComponentTitle : calculator.loadName,
|
||||
detail: powerDetail,
|
||||
iconSystemName: "bolt.fill",
|
||||
destination: deviceLink.map { .affiliate($0) } ?? .amazonSearch(deviceQueryBase),
|
||||
destination: componentID.map { .component($0) } ?? .search(deviceQueryBase),
|
||||
isPrimaryComponent: true
|
||||
)
|
||||
)
|
||||
@@ -858,7 +842,7 @@ struct CalculatorView: View {
|
||||
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
|
||||
detail: cableDetail,
|
||||
iconSystemName: "bolt.horizontal.circle",
|
||||
destination: .amazonSearch(redCableQuery),
|
||||
destination: .search(redCableQuery),
|
||||
isPrimaryComponent: false
|
||||
)
|
||||
)
|
||||
@@ -869,7 +853,7 @@ struct CalculatorView: View {
|
||||
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
|
||||
detail: cableDetail,
|
||||
iconSystemName: "bolt.horizontal.circle",
|
||||
destination: .amazonSearch(blackCableQuery),
|
||||
destination: .search(blackCableQuery),
|
||||
isPrimaryComponent: false
|
||||
)
|
||||
)
|
||||
@@ -880,7 +864,7 @@ struct CalculatorView: View {
|
||||
title: String(localized: "bom.item.fuse", comment: "Title for the fuse item"),
|
||||
detail: fuseDetail,
|
||||
iconSystemName: "bolt.shield",
|
||||
destination: .amazonSearch(fuseQuery),
|
||||
destination: .search(fuseQuery),
|
||||
isPrimaryComponent: false
|
||||
)
|
||||
)
|
||||
@@ -891,7 +875,7 @@ struct CalculatorView: View {
|
||||
title: String(localized: "bom.item.terminals", comment: "Title for the terminals item"),
|
||||
detail: cableShoesDetail,
|
||||
iconSystemName: "wrench.and.screwdriver",
|
||||
destination: .amazonSearch(terminalQuery),
|
||||
destination: .search(terminalQuery),
|
||||
isPrimaryComponent: false
|
||||
)
|
||||
)
|
||||
@@ -1495,11 +1479,11 @@ private struct BillOfMaterialsView: View {
|
||||
return
|
||||
}
|
||||
if let destinationURL {
|
||||
let isAffiliate: Bool
|
||||
if case .affiliate = item.destination { isAffiliate = true } else { isAffiliate = false }
|
||||
let isComponent: Bool
|
||||
if case .component = item.destination { isComponent = true } else { isComponent = false }
|
||||
AnalyticsTracker.log("BOM Item Tapped", properties: [
|
||||
"item": item.title,
|
||||
"is_affiliate": isAffiliate,
|
||||
"is_component": isComponent,
|
||||
"domain": destinationURL.host ?? "unknown",
|
||||
"load": info.id,
|
||||
])
|
||||
@@ -1543,10 +1527,10 @@ private struct BillOfMaterialsView: View {
|
||||
|
||||
private func destinationURL(for item: CalculatorView.BOMItem) -> URL? {
|
||||
switch item.destination {
|
||||
case .affiliate(let url):
|
||||
return url
|
||||
case .amazonSearch(let query):
|
||||
return AmazonAffiliate.searchURL(query: query, countryCode: info.countryCode)
|
||||
case .component(let id):
|
||||
return VoltPlanRedirect.componentURL(id: id)
|
||||
case .search(let query):
|
||||
return VoltPlanRedirect.searchURL(query: query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ComponentLibraryItem: Identifiable, Equatable {
|
||||
struct AffiliateLink: Identifiable, Equatable {
|
||||
let id: String
|
||||
let url: URL
|
||||
let country: String?
|
||||
}
|
||||
|
||||
let id: String
|
||||
let name: String
|
||||
let translations: [String: String]
|
||||
@@ -16,7 +10,6 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
||||
let dutyCyclePercent: Double?
|
||||
let defaultUtilizationFactorPercent: Double?
|
||||
let iconURL: URL?
|
||||
let affiliateLinks: [AffiliateLink]
|
||||
|
||||
var displayVoltage: Double? {
|
||||
voltageIn ?? voltageOut
|
||||
@@ -65,36 +58,10 @@ struct ComponentLibraryItem: Identifiable, Equatable {
|
||||
return translation(for: locale)
|
||||
}
|
||||
|
||||
var primaryAffiliateLink: AffiliateLink? {
|
||||
affiliateLink(matching: Locale.current.region?.identifier)
|
||||
}
|
||||
|
||||
func localizedName(for locale: Locale) -> String {
|
||||
translation(for: locale) ?? name
|
||||
}
|
||||
|
||||
func affiliateLink(matching regionCode: String?) -> AffiliateLink? {
|
||||
guard !affiliateLinks.isEmpty else { return nil }
|
||||
|
||||
let normalizedRegionCode = regionCode?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
|
||||
if let normalizedRegionCode, !normalizedRegionCode.isEmpty {
|
||||
if let exactMatch = affiliateLinks.first(where: { link in
|
||||
link.country?.lowercased() == normalizedRegionCode
|
||||
}) {
|
||||
return exactMatch
|
||||
}
|
||||
}
|
||||
|
||||
if let fallbackWithoutCountry = affiliateLinks.first(where: { $0.country == nil }) {
|
||||
return fallbackWithoutCountry
|
||||
}
|
||||
|
||||
return affiliateLinks.first
|
||||
}
|
||||
|
||||
private func translation(for locale: Locale) -> String? {
|
||||
guard !translations.isEmpty else { return nil }
|
||||
|
||||
@@ -289,7 +256,6 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
page += 1
|
||||
}
|
||||
|
||||
let affiliateLinksByComponent = try await fetchAffiliateLinks(for: allRecords.map(\.id))
|
||||
let mappedItems = allRecords.map { record in
|
||||
ComponentLibraryItem(
|
||||
id: record.id,
|
||||
@@ -300,8 +266,7 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
watt: record.watt,
|
||||
dutyCyclePercent: record.dutyCycle,
|
||||
defaultUtilizationFactorPercent: record.defaultUtilizationFactor,
|
||||
iconURL: iconURL(for: record),
|
||||
affiliateLinks: affiliateLinksByComponent[record.id] ?? []
|
||||
iconURL: iconURL(for: record)
|
||||
)
|
||||
}
|
||||
for item in mappedItems {
|
||||
@@ -314,110 +279,6 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
return mappedItems
|
||||
}
|
||||
|
||||
private func fetchAffiliateLinks(for componentIDs: [String]) async throws -> [String: [ComponentLibraryItem.AffiliateLink]] {
|
||||
let uniqueIDs = Array(Set(componentIDs))
|
||||
guard !uniqueIDs.isEmpty else { return [:] }
|
||||
|
||||
let idSet = Set(uniqueIDs)
|
||||
let perPage = 200
|
||||
let chunkSize = 15
|
||||
let chunks: [[String]] = stride(from: 0, to: uniqueIDs.count, by: chunkSize).map { index in
|
||||
let upperBound = min(index + chunkSize, uniqueIDs.count)
|
||||
return Array(uniqueIDs[index..<upperBound])
|
||||
}
|
||||
var aggregated: [String: [ComponentLibraryItem.AffiliateLink]] = [:]
|
||||
|
||||
for chunk in chunks {
|
||||
guard !chunk.isEmpty else { continue }
|
||||
|
||||
let filterValue = chunk
|
||||
.map { "component='\(escapeFilterValue($0))'" }
|
||||
.joined(separator: " || ")
|
||||
|
||||
var page = 1
|
||||
|
||||
while true {
|
||||
var components = URLComponents(
|
||||
url: baseURL.appendingPathComponent("api/collections/affiliate_links/records"),
|
||||
resolvingAgainstBaseURL: false
|
||||
)
|
||||
|
||||
var queryItems = [
|
||||
URLQueryItem(name: "page", value: "\(page)"),
|
||||
URLQueryItem(name: "perPage", value: "\(perPage)"),
|
||||
URLQueryItem(name: "fields", value: "id,url,component,country")
|
||||
]
|
||||
|
||||
if !filterValue.isEmpty {
|
||||
queryItems.append(URLQueryItem(name: "filter", value: "(\(filterValue))"))
|
||||
}
|
||||
|
||||
components?.queryItems = queryItems
|
||||
|
||||
guard let url = components?.url else {
|
||||
throw URLError(.badURL)
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
|
||||
let (data, response) = try await urlSession.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
let decoded = try JSONDecoder().decode(AffiliateLinksResponse.self, from: data)
|
||||
|
||||
for record in decoded.items {
|
||||
guard let componentID = record.component, idSet.contains(componentID) else { continue }
|
||||
guard let url = URL(string: record.url) else { continue }
|
||||
|
||||
let normalizedCountry = record.country?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let countryCode = normalizedCountry?.isEmpty == true ? nil : normalizedCountry?.uppercased()
|
||||
|
||||
let link = ComponentLibraryItem.AffiliateLink(
|
||||
id: record.id,
|
||||
url: url,
|
||||
country: countryCode
|
||||
)
|
||||
|
||||
var links = aggregated[componentID, default: []]
|
||||
if !links.contains(where: { $0.id == record.id }) {
|
||||
links.append(link)
|
||||
aggregated[componentID] = links
|
||||
}
|
||||
}
|
||||
|
||||
let isLastPage: Bool
|
||||
if decoded.totalPages > 0 {
|
||||
isLastPage = page >= decoded.totalPages
|
||||
} else {
|
||||
isLastPage = decoded.items.count < perPage
|
||||
}
|
||||
|
||||
if isLastPage { break }
|
||||
page += 1
|
||||
}
|
||||
}
|
||||
|
||||
for key in Array(aggregated.keys) {
|
||||
aggregated[key]?.sort { lhs, rhs in
|
||||
let lhsCountry = lhs.country ?? ""
|
||||
let rhsCountry = rhs.country ?? ""
|
||||
|
||||
if lhsCountry == rhsCountry {
|
||||
return lhs.url.absoluteString < rhs.url.absoluteString
|
||||
}
|
||||
|
||||
return lhsCountry < rhsCountry
|
||||
}
|
||||
}
|
||||
|
||||
return aggregated
|
||||
}
|
||||
|
||||
private func iconURL(for record: PocketBaseRecord) -> URL? {
|
||||
guard let icon = record.icon else { return nil }
|
||||
|
||||
@@ -518,18 +379,6 @@ final class ComponentLibraryViewModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private struct AffiliateLinksResponse: Decodable {
|
||||
let page: Int
|
||||
let totalPages: Int
|
||||
let items: [AffiliateLinkRecord]
|
||||
}
|
||||
|
||||
private struct AffiliateLinkRecord: Decodable {
|
||||
let id: String
|
||||
let url: String
|
||||
let component: String?
|
||||
let country: String?
|
||||
}
|
||||
}
|
||||
|
||||
struct ComponentLibraryView: View {
|
||||
|
||||
559
Cable/ScreenshotPreviews.swift
Normal file
559
Cable/ScreenshotPreviews.swift
Normal file
@@ -0,0 +1,559 @@
|
||||
//
|
||||
// ScreenshotPreviews.swift
|
||||
// Cable
|
||||
//
|
||||
// Screenshot previews for all views in all supported languages.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Sample Data
|
||||
|
||||
private enum ScreenshotData {
|
||||
@MainActor
|
||||
static func makeContainer() -> ModelContainer {
|
||||
let container = try! ModelContainer(
|
||||
for: ElectricalSystem.self, SavedLoad.self, SavedBattery.self, SavedCharger.self, Item.self,
|
||||
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
)
|
||||
let ctx = container.mainContext
|
||||
|
||||
// System 1: Sailboat
|
||||
let sailboat = ElectricalSystem(
|
||||
name: "Sailboat Aurora",
|
||||
location: "Marina 7",
|
||||
iconName: "sailboat",
|
||||
colorName: "blue",
|
||||
targetRuntimeHours: 15,
|
||||
targetChargeTimeHours: 3
|
||||
)
|
||||
ctx.insert(sailboat)
|
||||
|
||||
let sailLoads: [(String, Double, Double, Double, Double, Double, String, String, Double, Double)] = [
|
||||
("Navigation Lights", 12.8, 2.4, 28.8, 5.0, 2.5, "light.beacon.max", "red", 100, 10),
|
||||
("Refrigerator", 12.8, 4.0, 48.0, 3.0, 2.5, "refrigerator", "blue", 40, 24),
|
||||
("VHF Radio", 12.8, 6.0, 72.0, 8.0, 4.0, "antenna.radiowaves.left.and.right", "green", 30, 8),
|
||||
("Anchor Windlass", 12.8, 80.0, 960.0, 6.0, 35.0, "arrow.up.and.down", "orange", 5, 0.5),
|
||||
("LED Cabin Lights", 12.8, 1.5, 18.0, 4.0, 1.5, "lightbulb", "yellow", 100, 6),
|
||||
]
|
||||
for (name, voltage, current, power, length, crossSection, icon, color, duty, hours) in sailLoads {
|
||||
ctx.insert(SavedLoad(
|
||||
name: name, voltage: voltage, current: current, power: power,
|
||||
length: length, crossSection: crossSection,
|
||||
iconName: icon, colorName: color,
|
||||
dutyCyclePercent: duty, dailyUsageHours: hours,
|
||||
system: sailboat
|
||||
))
|
||||
}
|
||||
|
||||
ctx.insert(SavedBattery(
|
||||
name: "House Bank",
|
||||
nominalVoltage: 12.8, capacityAmpHours: 200,
|
||||
chemistry: .lithiumIronPhosphate,
|
||||
iconName: "battery.100.bolt", colorName: "green",
|
||||
system: sailboat
|
||||
))
|
||||
ctx.insert(SavedBattery(
|
||||
name: "Starter Battery",
|
||||
nominalVoltage: 12.0, capacityAmpHours: 90,
|
||||
chemistry: .agm,
|
||||
iconName: "bolt", colorName: "orange",
|
||||
system: sailboat
|
||||
))
|
||||
|
||||
ctx.insert(SavedCharger(
|
||||
name: "Shore Charger",
|
||||
inputVoltage: 230, outputVoltage: 14.4,
|
||||
maxCurrentAmps: 40, maxPowerWatts: 580,
|
||||
iconName: "powerplug", colorName: "orange",
|
||||
system: sailboat, powerSourceType: "shore"
|
||||
))
|
||||
ctx.insert(SavedCharger(
|
||||
name: "Solar MPPT",
|
||||
inputVoltage: 36, outputVoltage: 14.2,
|
||||
maxCurrentAmps: 30, maxPowerWatts: 426,
|
||||
iconName: "sun.max.fill", colorName: "yellow",
|
||||
system: sailboat, powerSourceType: "solar"
|
||||
))
|
||||
|
||||
// System 2: Camper Van
|
||||
let camper = ElectricalSystem(
|
||||
name: "Camper Van",
|
||||
location: "Road Trip",
|
||||
iconName: "bus",
|
||||
colorName: "teal",
|
||||
targetRuntimeHours: 24,
|
||||
targetChargeTimeHours: 4
|
||||
)
|
||||
ctx.insert(camper)
|
||||
|
||||
let camperLoads: [(String, Double, Double, Double, Double, Double, String, String)] = [
|
||||
("Water Pump", 12.8, 3.5, 42.0, 4.0, 2.5, "drop", "cyan"),
|
||||
("USB Charger", 12.8, 2.0, 24.0, 2.0, 1.5, "cable.connector", "gray"),
|
||||
]
|
||||
for (name, voltage, current, power, length, crossSection, icon, color) in camperLoads {
|
||||
ctx.insert(SavedLoad(
|
||||
name: name, voltage: voltage, current: current, power: power,
|
||||
length: length, crossSection: crossSection,
|
||||
iconName: icon, colorName: color, system: camper
|
||||
))
|
||||
}
|
||||
|
||||
ctx.insert(SavedBattery(
|
||||
name: "LiFePO4 200Ah",
|
||||
nominalVoltage: 12.8, capacityAmpHours: 200,
|
||||
chemistry: .lithiumIronPhosphate,
|
||||
iconName: "battery.100.bolt", colorName: "green",
|
||||
system: camper
|
||||
))
|
||||
|
||||
// System 3: Cabin (empty, for variety)
|
||||
let cabin = ElectricalSystem(
|
||||
name: "Off-Grid Cabin",
|
||||
location: "Mountains",
|
||||
iconName: "house",
|
||||
colorName: "brown"
|
||||
)
|
||||
ctx.insert(cabin)
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
@MainActor
|
||||
static func firstSystem(in container: ModelContainer) -> ElectricalSystem {
|
||||
let systems = try! container.mainContext.fetch(
|
||||
FetchDescriptor<ElectricalSystem>(sortBy: [SortDescriptor(\.timestamp)])
|
||||
)
|
||||
return systems.first!
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Wrapper Views
|
||||
|
||||
private struct LoadsViewScreenshot: View {
|
||||
let container: ModelContainer
|
||||
let system: ElectricalSystem
|
||||
var initialTab: LoadsView.ComponentTab = .overview
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
LoadsView(system: system, initialTab: initialTab)
|
||||
}
|
||||
.modelContainer(container)
|
||||
.environmentObject(UnitSystemSettings())
|
||||
}
|
||||
}
|
||||
|
||||
private struct SystemsViewScreenshot: View {
|
||||
let container: ModelContainer
|
||||
|
||||
var body: some View {
|
||||
SystemsView()
|
||||
.modelContainer(container)
|
||||
.environmentObject(UnitSystemSettings())
|
||||
}
|
||||
}
|
||||
|
||||
private struct CalculatorViewScreenshot: View {
|
||||
let container: ModelContainer
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
CalculatorView()
|
||||
}
|
||||
.modelContainer(container)
|
||||
.environmentObject(UnitSystemSettings())
|
||||
}
|
||||
}
|
||||
|
||||
private struct BatteryEditorScreenshot: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
BatteryEditorView(
|
||||
configuration: BatteryConfiguration(
|
||||
name: "House Bank",
|
||||
nominalVoltage: 12.8,
|
||||
capacityAmpHours: 200,
|
||||
chemistry: .lithiumIronPhosphate,
|
||||
iconName: "battery.100.bolt",
|
||||
colorName: "green",
|
||||
system: ElectricalSystem(name: "Sailboat Aurora")
|
||||
),
|
||||
onSave: { _ in }
|
||||
)
|
||||
}
|
||||
.environmentObject(UnitSystemSettings())
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChargerEditorScreenshot: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ChargerEditorView(
|
||||
configuration: ChargerConfiguration(
|
||||
name: "Shore Charger",
|
||||
inputVoltage: 230,
|
||||
outputVoltage: 14.4,
|
||||
maxCurrentAmps: 40,
|
||||
maxPowerWatts: 580,
|
||||
iconName: "powerplug",
|
||||
colorName: "orange",
|
||||
system: ElectricalSystem(name: "Sailboat Aurora")
|
||||
),
|
||||
onSave: { _ in }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ComponentLibraryScreenshot: View {
|
||||
var body: some View {
|
||||
ComponentLibraryView(
|
||||
viewModel: ComponentLibraryViewModel(previewItems: Self.sampleItems),
|
||||
onSelect: { _ in }
|
||||
)
|
||||
}
|
||||
|
||||
private static let sampleItems: [ComponentLibraryItem] = [
|
||||
ComponentLibraryItem(
|
||||
id: "1", name: "Navigation Lights",
|
||||
translations: ["de": "Navigationslichter", "es": "Luces de navegación", "fr": "Feux de navigation", "nl": "Navigatieverlichting"],
|
||||
voltageIn: 12.8, voltageOut: nil, watt: 25,
|
||||
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 40,
|
||||
iconURL: nil
|
||||
),
|
||||
ComponentLibraryItem(
|
||||
id: "2", name: "Refrigerator Compressor",
|
||||
translations: ["de": "Kühlschrank-Kompressor", "es": "Compresor de refrigerador", "fr": "Compresseur réfrigérateur", "nl": "Koelkastcompressor"],
|
||||
voltageIn: 12.8, voltageOut: nil, watt: 48,
|
||||
dutyCyclePercent: 40, defaultUtilizationFactorPercent: 100,
|
||||
iconURL: nil
|
||||
),
|
||||
ComponentLibraryItem(
|
||||
id: "3", name: "Anchor Windlass",
|
||||
translations: ["de": "Ankerwinde", "es": "Molinete de ancla", "fr": "Guindeau", "nl": "Ankerlier"],
|
||||
voltageIn: 12.8, voltageOut: nil, watt: 960,
|
||||
dutyCyclePercent: 5, defaultUtilizationFactorPercent: 2,
|
||||
iconURL: nil
|
||||
),
|
||||
ComponentLibraryItem(
|
||||
id: "4", name: "VHF Radio",
|
||||
translations: ["de": "UKW Funkgerät", "es": "Radio VHF", "fr": "Radio VHF", "nl": "Marifoon"],
|
||||
voltageIn: 12.8, voltageOut: nil, watt: 72,
|
||||
dutyCyclePercent: 30, defaultUtilizationFactorPercent: 33,
|
||||
iconURL: nil
|
||||
),
|
||||
ComponentLibraryItem(
|
||||
id: "5", name: "LED Interior Lights",
|
||||
translations: ["de": "LED Innenbeleuchtung", "es": "Iluminación LED interior", "fr": "Éclairage LED intérieur", "nl": "LED binnenverlichting"],
|
||||
voltageIn: 12.8, voltageOut: nil, watt: 18,
|
||||
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 25,
|
||||
iconURL: nil
|
||||
),
|
||||
ComponentLibraryItem(
|
||||
id: "6", name: "Water Pump",
|
||||
translations: ["de": "Wasserpumpe", "es": "Bomba de agua", "fr": "Pompe à eau", "nl": "Waterpomp"],
|
||||
voltageIn: 12.8, voltageOut: nil, watt: 42,
|
||||
dutyCyclePercent: 20, defaultUtilizationFactorPercent: 10,
|
||||
iconURL: nil
|
||||
),
|
||||
ComponentLibraryItem(
|
||||
id: "7", name: "Diesel Heater",
|
||||
translations: ["de": "Dieselheizung", "es": "Calefactor diésel", "fr": "Chauffage diesel", "nl": "Dieselverwarming"],
|
||||
voltageIn: 12.8, voltageOut: nil, watt: 36,
|
||||
dutyCyclePercent: 60, defaultUtilizationFactorPercent: 50,
|
||||
iconURL: nil
|
||||
),
|
||||
ComponentLibraryItem(
|
||||
id: "8", name: "USB Charging Station",
|
||||
translations: ["de": "USB-Ladestation", "es": "Estación de carga USB", "fr": "Station de charge USB", "nl": "USB-laadstation"],
|
||||
voltageIn: 12.8, voltageOut: nil, watt: 24,
|
||||
dutyCyclePercent: 100, defaultUtilizationFactorPercent: 30,
|
||||
iconURL: nil
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Language helper
|
||||
|
||||
private let locales: [(String, String)] = [
|
||||
("EN", "en"),
|
||||
("DE", "de"),
|
||||
("ES", "es"),
|
||||
("FR", "fr"),
|
||||
("NL", "nl"),
|
||||
]
|
||||
|
||||
// MARK: - 1. Overview Tab
|
||||
|
||||
#Preview("Overview – EN") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .overview)
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("Overview – DE") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .overview)
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("Overview – ES") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .overview)
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("Overview – FR") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .overview)
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("Overview – NL") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .overview)
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
|
||||
// MARK: - 2. Components Tab
|
||||
|
||||
#Preview("Components – EN") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .components)
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("Components – DE") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .components)
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("Components – ES") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .components)
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("Components – FR") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .components)
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("Components – NL") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .components)
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
|
||||
// MARK: - 3. Batteries Tab
|
||||
|
||||
#Preview("Batteries – EN") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .batteries)
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("Batteries – DE") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .batteries)
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("Batteries – ES") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .batteries)
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("Batteries – FR") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .batteries)
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("Batteries – NL") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .batteries)
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
|
||||
// MARK: - 4. Chargers Tab
|
||||
|
||||
#Preview("Chargers – EN") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .chargers)
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("Chargers – DE") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .chargers)
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("Chargers – ES") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .chargers)
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("Chargers – FR") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .chargers)
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("Chargers – NL") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
LoadsViewScreenshot(container: c, system: ScreenshotData.firstSystem(in: c), initialTab: .chargers)
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
|
||||
// MARK: - 5. Systems List
|
||||
|
||||
#Preview("Systems – EN") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
SystemsViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("Systems – DE") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
SystemsViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("Systems – ES") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
SystemsViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("Systems – FR") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
SystemsViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("Systems – NL") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
SystemsViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
|
||||
// MARK: - 6. Parts Library
|
||||
|
||||
#Preview("Library – EN") {
|
||||
ComponentLibraryScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("Library – DE") {
|
||||
ComponentLibraryScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("Library – ES") {
|
||||
ComponentLibraryScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("Library – FR") {
|
||||
ComponentLibraryScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("Library – NL") {
|
||||
ComponentLibraryScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
|
||||
// MARK: - 7. Load Editor (Calculator)
|
||||
|
||||
#Preview("LoadEditor – EN") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
CalculatorViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("LoadEditor – DE") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
CalculatorViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("LoadEditor – ES") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
CalculatorViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("LoadEditor – FR") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
CalculatorViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("LoadEditor – NL") {
|
||||
let c = ScreenshotData.makeContainer()
|
||||
CalculatorViewScreenshot(container: c)
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
|
||||
// MARK: - 8. Battery Editor
|
||||
|
||||
#Preview("BatteryEditor – EN") {
|
||||
BatteryEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("BatteryEditor – DE") {
|
||||
BatteryEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("BatteryEditor – ES") {
|
||||
BatteryEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("BatteryEditor – FR") {
|
||||
BatteryEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("BatteryEditor – NL") {
|
||||
BatteryEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
|
||||
// MARK: - 9. Charger Editor
|
||||
|
||||
#Preview("ChargerEditor – EN") {
|
||||
ChargerEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "en"))
|
||||
}
|
||||
|
||||
#Preview("ChargerEditor – DE") {
|
||||
ChargerEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "de"))
|
||||
}
|
||||
|
||||
#Preview("ChargerEditor – ES") {
|
||||
ChargerEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "es"))
|
||||
}
|
||||
|
||||
#Preview("ChargerEditor – FR") {
|
||||
ChargerEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "fr"))
|
||||
}
|
||||
|
||||
#Preview("ChargerEditor – NL") {
|
||||
ChargerEditorScreenshot()
|
||||
.environment(\.locale, Locale(identifier: "nl"))
|
||||
}
|
||||
@@ -111,8 +111,8 @@ struct SystemBillOfMaterialsView: View {
|
||||
|
||||
private struct Item: Identifiable {
|
||||
enum Destination {
|
||||
case affiliate(URL)
|
||||
case amazonSearch(String)
|
||||
case component(String)
|
||||
case search(String)
|
||||
}
|
||||
|
||||
let id: String
|
||||
@@ -436,12 +436,12 @@ struct SystemBillOfMaterialsView: View {
|
||||
}
|
||||
|
||||
private func trackAffiliateTap(item: Item, url: URL) {
|
||||
let isAffiliate: Bool
|
||||
if case .affiliate = item.destination { isAffiliate = true } else { isAffiliate = false }
|
||||
let isComponent: Bool
|
||||
if case .component = item.destination { isComponent = true } else { isComponent = false }
|
||||
AnalyticsTracker.log("BOM Item Tapped", properties: [
|
||||
"item": item.title,
|
||||
"category": item.category.rawValue,
|
||||
"is_affiliate": isAffiliate,
|
||||
"is_component": isComponent,
|
||||
"domain": url.host ?? "unknown",
|
||||
"system": systemName,
|
||||
])
|
||||
@@ -585,7 +585,6 @@ struct SystemBillOfMaterialsView: View {
|
||||
cableShoesDetailFormat,
|
||||
crossSectionLabel.lowercased()
|
||||
)
|
||||
let affiliateURL = load.affiliateURLString.flatMap { URL(string: $0) }
|
||||
let deviceFallbackFormat = String(localized: "bom.search.device.fallback", defaultValue: "DC device %.0fW %.0fV")
|
||||
let deviceQuery = load.name.isEmpty
|
||||
? String(format: deviceFallbackFormat, calculatedPower, load.voltage)
|
||||
@@ -619,7 +618,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
title: load.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : load.name,
|
||||
detail: powerDetail,
|
||||
iconSystemName: "bolt.fill",
|
||||
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(deviceQuery),
|
||||
destination: load.componentID.map { .component($0) } ?? .search(deviceQuery),
|
||||
isPrimaryComponent: true,
|
||||
components: [component],
|
||||
category: .components,
|
||||
@@ -636,7 +635,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
|
||||
detail: "",
|
||||
iconSystemName: "bolt.horizontal.circle",
|
||||
destination: .amazonSearch(redCableQuery),
|
||||
destination: .search(redCableQuery),
|
||||
isPrimaryComponent: false,
|
||||
components: [component],
|
||||
category: .cables,
|
||||
@@ -653,7 +652,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
|
||||
detail: "",
|
||||
iconSystemName: "bolt.horizontal.circle",
|
||||
destination: .amazonSearch(blackCableQuery),
|
||||
destination: .search(blackCableQuery),
|
||||
isPrimaryComponent: false,
|
||||
components: [component],
|
||||
category: .cables,
|
||||
@@ -670,7 +669,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
title: String(localized: "bom.item.fuse", comment: "Title for the fuse and holder item"),
|
||||
detail: fuseDetail,
|
||||
iconSystemName: "bolt.shield",
|
||||
destination: .amazonSearch(fuseQuery),
|
||||
destination: .search(fuseQuery),
|
||||
isPrimaryComponent: false,
|
||||
components: [component],
|
||||
category: .fuses,
|
||||
@@ -687,7 +686,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
title: String(localized: "bom.item.terminals", comment: "Title for the cable terminals item"),
|
||||
detail: cableShoesDetail,
|
||||
iconSystemName: "wrench.and.screwdriver",
|
||||
destination: .amazonSearch(terminalQuery),
|
||||
destination: .search(terminalQuery),
|
||||
isPrimaryComponent: false,
|
||||
components: [component],
|
||||
category: .accessories,
|
||||
@@ -712,7 +711,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
let voltageQuery = max(1, Int(round(battery.nominalVoltage)))
|
||||
let batterySearchFormat = String(localized: "bom.search.battery", defaultValue: "%dAh %dV %@ battery")
|
||||
let query = String(format: batterySearchFormat, capacityQuery, voltageQuery, battery.chemistry.displayName)
|
||||
let affiliateURL = battery.affiliateURLString.flatMap { URL(string: $0) }
|
||||
let componentID = battery.componentID
|
||||
let storageKey = Self.storageKey(for: component, itemID: "battery")
|
||||
|
||||
return [
|
||||
@@ -723,7 +722,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
title: battery.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : battery.name,
|
||||
detail: detail,
|
||||
iconSystemName: battery.iconName,
|
||||
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(query),
|
||||
destination: componentID.map { .component($0) } ?? .search(query),
|
||||
isPrimaryComponent: true,
|
||||
components: [component],
|
||||
category: .batteries,
|
||||
@@ -746,7 +745,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
let voltageQuery = max(1, Int(round(charger.outputVoltage)))
|
||||
let currentQuery = max(1, Int(round(charger.maxCurrentAmps)))
|
||||
let query = "\(voltageQuery)V \(currentQuery)A battery charger"
|
||||
let affiliateURL = charger.affiliateURLString.flatMap { URL(string: $0) }
|
||||
let componentID = charger.componentID
|
||||
let storageKey = Self.storageKey(for: component, itemID: "charger")
|
||||
|
||||
return [
|
||||
@@ -757,7 +756,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
title: charger.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : charger.name,
|
||||
detail: detail,
|
||||
iconSystemName: charger.iconName,
|
||||
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(query),
|
||||
destination: componentID.map { .component($0) } ?? .search(query),
|
||||
isPrimaryComponent: true,
|
||||
components: [component],
|
||||
category: .components,
|
||||
@@ -772,22 +771,10 @@ struct SystemBillOfMaterialsView: View {
|
||||
|
||||
private func destinationURL(for destination: Item.Destination, component: Component) -> URL? {
|
||||
switch destination {
|
||||
case .affiliate(let url):
|
||||
return url
|
||||
case .amazonSearch(let query):
|
||||
let countryCode = affiliateCountryCode(for: component) ?? Locale.current.region?.identifier
|
||||
return AmazonAffiliate.searchURL(query: query, countryCode: countryCode)
|
||||
}
|
||||
}
|
||||
|
||||
private func affiliateCountryCode(for component: Component) -> String? {
|
||||
switch component {
|
||||
case .load(let load):
|
||||
return load.affiliateCountryCode
|
||||
case .battery(let battery):
|
||||
return battery.affiliateCountryCode
|
||||
case .charger(let charger):
|
||||
return charger.affiliateCountryCode
|
||||
case .component(let id):
|
||||
return VoltPlanRedirect.componentURL(id: id)
|
||||
case .search(let query):
|
||||
return VoltPlanRedirect.searchURL(query: query)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -905,8 +892,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
dailyUsageHours: 0,
|
||||
system: nil,
|
||||
remoteIconURLString: nil,
|
||||
affiliateURLString: nil,
|
||||
affiliateCountryCode: nil,
|
||||
componentID: nil,
|
||||
bomCompletedItemIDs: [],
|
||||
identifier: UUID().uuidString
|
||||
)
|
||||
@@ -952,8 +938,7 @@ struct SystemBillOfMaterialsView: View {
|
||||
dailyUsageHours: 0,
|
||||
system: nil,
|
||||
remoteIconURLString: nil,
|
||||
affiliateURLString: nil,
|
||||
affiliateCountryCode: nil,
|
||||
componentID: nil,
|
||||
bomCompletedItemIDs: ["component", "cable-red"],
|
||||
identifier: UUID().uuidString
|
||||
)
|
||||
|
||||
@@ -66,7 +66,6 @@ struct SystemComponentsPersistence {
|
||||
current = 0
|
||||
}
|
||||
|
||||
let affiliateLink = item.primaryAffiliateLink
|
||||
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
|
||||
let dailyUsageHours = item.defaultDailyUsageHours ?? 1
|
||||
|
||||
@@ -84,8 +83,7 @@ struct SystemComponentsPersistence {
|
||||
dailyUsageHours: dailyUsageHours,
|
||||
system: system,
|
||||
remoteIconURLString: item.iconURL?.absoluteString,
|
||||
affiliateURLString: affiliateLink?.url.absoluteString,
|
||||
affiliateCountryCode: affiliateLink?.country
|
||||
componentID: item.id
|
||||
)
|
||||
|
||||
context.insert(newLoad)
|
||||
|
||||
@@ -372,7 +372,6 @@ struct SystemsView: View {
|
||||
current = 0
|
||||
}
|
||||
|
||||
let affiliateLink = item.primaryAffiliateLink
|
||||
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
|
||||
let dailyUsageHours = item.defaultDailyUsageHours ?? 1
|
||||
|
||||
@@ -390,8 +389,7 @@ struct SystemsView: View {
|
||||
dailyUsageHours: dailyUsageHours,
|
||||
system: system,
|
||||
remoteIconURLString: item.iconURL?.absoluteString,
|
||||
affiliateURLString: affiliateLink?.url.absoluteString,
|
||||
affiliateCountryCode: affiliateLink?.country
|
||||
componentID: item.id
|
||||
)
|
||||
|
||||
modelContext.insert(newLoad)
|
||||
@@ -549,8 +547,7 @@ struct SystemsView: View {
|
||||
isWattMode: false,
|
||||
system: system1,
|
||||
remoteIconURLString: nil,
|
||||
affiliateURLString: nil,
|
||||
affiliateCountryCode: nil
|
||||
componentID: nil
|
||||
)
|
||||
|
||||
let load2 = SavedLoad(
|
||||
@@ -565,8 +562,7 @@ struct SystemsView: View {
|
||||
isWattMode: false,
|
||||
system: system1,
|
||||
remoteIconURLString: nil,
|
||||
affiliateURLString: nil,
|
||||
affiliateCountryCode: nil
|
||||
componentID: nil
|
||||
)
|
||||
|
||||
// Sample loads for system 2
|
||||
@@ -582,8 +578,7 @@ struct SystemsView: View {
|
||||
isWattMode: false,
|
||||
system: system2,
|
||||
remoteIconURLString: nil,
|
||||
affiliateURLString: nil,
|
||||
affiliateCountryCode: nil
|
||||
componentID: nil
|
||||
)
|
||||
|
||||
context.insert(load1)
|
||||
|
||||
@@ -15,7 +15,6 @@ struct ComponentLibraryItemTests {
|
||||
dutyCyclePercent: nil,
|
||||
defaultUtilizationFactorPercent: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let german = Foundation.Locale(identifier: "de_DE")
|
||||
@@ -33,7 +32,6 @@ struct ComponentLibraryItemTests {
|
||||
dutyCyclePercent: nil,
|
||||
defaultUtilizationFactorPercent: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let french = Foundation.Locale(identifier: "fr_FR")
|
||||
@@ -54,7 +52,6 @@ struct ComponentLibraryItemTests {
|
||||
dutyCyclePercent: nil,
|
||||
defaultUtilizationFactorPercent: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let languages = ["fr-FR", "de-DE", "es-ES"]
|
||||
@@ -72,7 +69,6 @@ struct ComponentLibraryItemTests {
|
||||
dutyCyclePercent: nil,
|
||||
defaultUtilizationFactorPercent: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let spanishMexico = Foundation.Locale(identifier: "es_MX")
|
||||
@@ -90,7 +86,6 @@ struct ComponentLibraryItemTests {
|
||||
dutyCyclePercent: nil,
|
||||
defaultUtilizationFactorPercent: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let germanSwitzerland = Foundation.Locale(identifier: "de_CH")
|
||||
@@ -108,7 +103,6 @@ struct ComponentLibraryItemTests {
|
||||
dutyCyclePercent: nil,
|
||||
defaultUtilizationFactorPercent: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
let french = Foundation.Locale(identifier: "fr_FR")
|
||||
@@ -126,7 +120,6 @@ struct ComponentLibraryItemTests {
|
||||
dutyCyclePercent: 0,
|
||||
defaultUtilizationFactorPercent: nil,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
#expect(item.normalizedDutyCyclePercent == 100)
|
||||
@@ -143,7 +136,6 @@ struct ComponentLibraryItemTests {
|
||||
dutyCyclePercent: nil,
|
||||
defaultUtilizationFactorPercent: 50,
|
||||
iconURL: nil,
|
||||
affiliateLinks: []
|
||||
)
|
||||
|
||||
#expect(item.defaultDailyUsageHours == 12)
|
||||
|
||||
Reference in New Issue
Block a user