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:
2026-04-04 22:52:12 +02:00
parent 61f340a870
commit 8b30fabaa2
11 changed files with 629 additions and 332 deletions

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}
}
}

View File

@@ -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 {

View 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"))
}

View File

@@ -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
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)