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 import Foundation
enum AmazonAffiliate { enum VoltPlanRedirect {
private static let fallbackDomain = "www.amazon.com" private static let baseURL = "https://voltplan.app"
private static let fallbackTag: String? = "voltplan-20"
private static let domainsByCountry: [String: String] = [ static func componentURL(id: String) -> URL? {
"US": "www.amazon.com", var components = URLComponents(string: "\(baseURL)/\(id)")
"DE": "www.amazon.de", components?.queryItems = [URLQueryItem(name: "src", value: "cable")]
"FR": "www.amazon.fr", return components?.url
"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"
]
// Configure Amazon affiliate tracking IDs by country code. static func searchURL(query: String) -> URL? {
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? {
guard !query.isEmpty else { return nil } guard !query.isEmpty else { return nil }
var components = URLComponents(string: "\(baseURL)/search")
var components = URLComponents() components?.queryItems = [
components.scheme = "https" URLQueryItem(name: "q", value: query),
components.host = domain(for: countryCode) URLQueryItem(name: "src", value: "cable"),
components.path = "/s" ]
return components?.url
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
} }
} }

View File

@@ -16,8 +16,7 @@ class SavedBattery {
var iconName: String = "battery.100" var iconName: String = "battery.100"
var colorName: String = "blue" var colorName: String = "blue"
var system: ElectricalSystem? var system: ElectricalSystem?
var affiliateURLString: String? var componentID: String?
var affiliateCountryCode: String?
var bomCompletedItemIDs: [String] = [] var bomCompletedItemIDs: [String] = []
var timestamp: Date var timestamp: Date
@@ -35,8 +34,7 @@ class SavedBattery {
iconName: String = "battery.100", iconName: String = "battery.100",
colorName: String = "blue", colorName: String = "blue",
system: ElectricalSystem? = nil, system: ElectricalSystem? = nil,
affiliateURLString: String? = nil, componentID: String? = nil,
affiliateCountryCode: String? = nil,
bomCompletedItemIDs: [String] = [], bomCompletedItemIDs: [String] = [],
timestamp: Date = Date() timestamp: Date = Date()
) { ) {
@@ -53,8 +51,7 @@ class SavedBattery {
self.iconName = iconName self.iconName = iconName
self.colorName = colorName self.colorName = colorName
self.system = system self.system = system
self.affiliateURLString = affiliateURLString self.componentID = componentID
self.affiliateCountryCode = affiliateCountryCode
self.bomCompletedItemIDs = bomCompletedItemIDs self.bomCompletedItemIDs = bomCompletedItemIDs
self.timestamp = timestamp self.timestamp = timestamp
} }

View File

@@ -14,8 +14,7 @@ final class SavedCharger {
var system: ElectricalSystem? var system: ElectricalSystem?
var timestamp: Date var timestamp: Date
var remoteIconURLString: String? var remoteIconURLString: String?
var affiliateURLString: String? var componentID: String?
var affiliateCountryCode: String?
var bomCompletedItemIDs: [String] = [] var bomCompletedItemIDs: [String] = []
var identifier: String var identifier: String
var powerSourceType: String = "shore" var powerSourceType: String = "shore"
@@ -67,8 +66,7 @@ final class SavedCharger {
system: ElectricalSystem? = nil, system: ElectricalSystem? = nil,
timestamp: Date = Date(), timestamp: Date = Date(),
remoteIconURLString: String? = nil, remoteIconURLString: String? = nil,
affiliateURLString: String? = nil, componentID: String? = nil,
affiliateCountryCode: String? = nil,
bomCompletedItemIDs: [String] = [], bomCompletedItemIDs: [String] = [],
identifier: String = UUID().uuidString, identifier: String = UUID().uuidString,
powerSourceType: String = "shore" powerSourceType: String = "shore"
@@ -84,8 +82,7 @@ final class SavedCharger {
self.system = system self.system = system
self.timestamp = timestamp self.timestamp = timestamp
self.remoteIconURLString = remoteIconURLString self.remoteIconURLString = remoteIconURLString
self.affiliateURLString = affiliateURLString self.componentID = componentID
self.affiliateCountryCode = affiliateCountryCode
self.bomCompletedItemIDs = bomCompletedItemIDs self.bomCompletedItemIDs = bomCompletedItemIDs
self.identifier = identifier self.identifier = identifier
self.powerSourceType = powerSourceType self.powerSourceType = powerSourceType

View File

@@ -127,8 +127,7 @@ class SavedLoad {
var dailyUsageHours: Double = 24.0 var dailyUsageHours: Double = 24.0
var system: ElectricalSystem? var system: ElectricalSystem?
var remoteIconURLString: String? = nil var remoteIconURLString: String? = nil
var affiliateURLString: String? = nil var componentID: String? = nil
var affiliateCountryCode: String? = nil
var bomCompletedItemIDs: [String] = [] var bomCompletedItemIDs: [String] = []
var identifier: String = UUID().uuidString var identifier: String = UUID().uuidString
@@ -146,8 +145,7 @@ class SavedLoad {
dailyUsageHours: Double = 24.0, dailyUsageHours: Double = 24.0,
system: ElectricalSystem? = nil, system: ElectricalSystem? = nil,
remoteIconURLString: String? = nil, remoteIconURLString: String? = nil,
affiliateURLString: String? = nil, componentID: String? = nil,
affiliateCountryCode: String? = nil,
bomCompletedItemIDs: [String] = [], bomCompletedItemIDs: [String] = [],
identifier: String = UUID().uuidString identifier: String = UUID().uuidString
) { ) {
@@ -165,8 +163,7 @@ class SavedLoad {
self.dailyUsageHours = dailyUsageHours self.dailyUsageHours = dailyUsageHours
self.system = system self.system = system
self.remoteIconURLString = remoteIconURLString self.remoteIconURLString = remoteIconURLString
self.affiliateURLString = affiliateURLString self.componentID = componentID
self.affiliateCountryCode = affiliateCountryCode
self.bomCompletedItemIDs = bomCompletedItemIDs self.bomCompletedItemIDs = bomCompletedItemIDs
self.identifier = identifier self.identifier = identifier
} }

View File

@@ -51,16 +51,14 @@ struct CalculatorView: View {
struct AffiliateLinkInfo: Identifiable, Equatable { struct AffiliateLinkInfo: Identifiable, Equatable {
let id: String let id: String
let affiliateURL: URL? let componentID: String?
let buttonTitle: String let buttonTitle: String
let regionName: String?
let countryCode: String?
} }
struct BOMItem: Identifiable, Equatable { struct BOMItem: Identifiable, Equatable {
enum Destination: Equatable { enum Destination: Equatable {
case affiliate(URL) case component(String)
case amazonSearch(String) case search(String)
} }
let id: String let id: String
@@ -391,7 +389,7 @@ struct CalculatorView: View {
private func billOfMaterialsSheet(info: AffiliateLinkInfo) -> some View { private func billOfMaterialsSheet(info: AffiliateLinkInfo) -> some View {
BillOfMaterialsView( BillOfMaterialsView(
info: info, info: info,
items: buildBillOfMaterialsItems(deviceLink: info.affiliateURL), items: buildBillOfMaterialsItems(componentID: info.componentID),
completedItemIDs: $completedItemIDs completedItemIDs: $completedItemIDs
) )
} }
@@ -467,18 +465,6 @@ struct CalculatorView: View {
} }
private var affiliateLinkInfo: AffiliateLinkInfo { 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 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 name = savedLoad?.name ?? calculator.loadName
let timestamp = savedLoad?.timestamp ?? Date(timeIntervalSince1970: 0) let timestamp = savedLoad?.timestamp ?? Date(timeIntervalSince1970: 0)
@@ -486,10 +472,8 @@ struct CalculatorView: View {
return AffiliateLinkInfo( return AffiliateLinkInfo(
id: identifier, id: identifier,
affiliateURL: affiliateURL, componentID: savedLoad?.componentID,
buttonTitle: buttonTitle, buttonTitle: buttonTitle
regionName: regionName,
countryCode: countryCode
) )
} }
@@ -756,7 +740,7 @@ struct CalculatorView: View {
Button { Button {
AnalyticsTracker.log("Review Parts Tapped", properties: [ AnalyticsTracker.log("Review Parts Tapped", properties: [
"load": info.id, "load": info.id,
"has_affiliate": info.affiliateURL != nil, "has_component": info.componentID != nil,
]) ])
presentedAffiliateLink = info presentedAffiliateLink = info
} label: { } label: {
@@ -773,7 +757,7 @@ struct CalculatorView: View {
} }
.buttonStyle(.plain) .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.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.") : 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) Text(description)
@@ -785,7 +769,7 @@ struct CalculatorView: View {
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
} }
private func buildBillOfMaterialsItems(deviceLink: URL?) -> [BOMItem] { private func buildBillOfMaterialsItems(componentID: String?) -> [BOMItem] {
let unitSystem = unitSettings.unitSystem let unitSystem = unitSettings.unitSystem
let lengthValue = calculator.length let lengthValue = calculator.length
let lengthLabel = String(format: "%.1f %@", lengthValue, unitSystem.lengthUnit) let lengthLabel = String(format: "%.1f %@", lengthValue, unitSystem.lengthUnit)
@@ -847,7 +831,7 @@ struct CalculatorView: View {
title: calculator.loadName.isEmpty ? fallbackComponentTitle : calculator.loadName, title: calculator.loadName.isEmpty ? fallbackComponentTitle : calculator.loadName,
detail: powerDetail, detail: powerDetail,
iconSystemName: "bolt.fill", iconSystemName: "bolt.fill",
destination: deviceLink.map { .affiliate($0) } ?? .amazonSearch(deviceQueryBase), destination: componentID.map { .component($0) } ?? .search(deviceQueryBase),
isPrimaryComponent: true isPrimaryComponent: true
) )
) )
@@ -858,7 +842,7 @@ struct CalculatorView: View {
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"), title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
detail: cableDetail, detail: cableDetail,
iconSystemName: "bolt.horizontal.circle", iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(redCableQuery), destination: .search(redCableQuery),
isPrimaryComponent: false isPrimaryComponent: false
) )
) )
@@ -869,7 +853,7 @@ struct CalculatorView: View {
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"), title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
detail: cableDetail, detail: cableDetail,
iconSystemName: "bolt.horizontal.circle", iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(blackCableQuery), destination: .search(blackCableQuery),
isPrimaryComponent: false isPrimaryComponent: false
) )
) )
@@ -880,7 +864,7 @@ struct CalculatorView: View {
title: String(localized: "bom.item.fuse", comment: "Title for the fuse item"), title: String(localized: "bom.item.fuse", comment: "Title for the fuse item"),
detail: fuseDetail, detail: fuseDetail,
iconSystemName: "bolt.shield", iconSystemName: "bolt.shield",
destination: .amazonSearch(fuseQuery), destination: .search(fuseQuery),
isPrimaryComponent: false isPrimaryComponent: false
) )
) )
@@ -891,7 +875,7 @@ struct CalculatorView: View {
title: String(localized: "bom.item.terminals", comment: "Title for the terminals item"), title: String(localized: "bom.item.terminals", comment: "Title for the terminals item"),
detail: cableShoesDetail, detail: cableShoesDetail,
iconSystemName: "wrench.and.screwdriver", iconSystemName: "wrench.and.screwdriver",
destination: .amazonSearch(terminalQuery), destination: .search(terminalQuery),
isPrimaryComponent: false isPrimaryComponent: false
) )
) )
@@ -1495,11 +1479,11 @@ private struct BillOfMaterialsView: View {
return return
} }
if let destinationURL { if let destinationURL {
let isAffiliate: Bool let isComponent: Bool
if case .affiliate = item.destination { isAffiliate = true } else { isAffiliate = false } if case .component = item.destination { isComponent = true } else { isComponent = false }
AnalyticsTracker.log("BOM Item Tapped", properties: [ AnalyticsTracker.log("BOM Item Tapped", properties: [
"item": item.title, "item": item.title,
"is_affiliate": isAffiliate, "is_component": isComponent,
"domain": destinationURL.host ?? "unknown", "domain": destinationURL.host ?? "unknown",
"load": info.id, "load": info.id,
]) ])
@@ -1543,10 +1527,10 @@ private struct BillOfMaterialsView: View {
private func destinationURL(for item: CalculatorView.BOMItem) -> URL? { private func destinationURL(for item: CalculatorView.BOMItem) -> URL? {
switch item.destination { switch item.destination {
case .affiliate(let url): case .component(let id):
return url return VoltPlanRedirect.componentURL(id: id)
case .amazonSearch(let query): case .search(let query):
return AmazonAffiliate.searchURL(query: query, countryCode: info.countryCode) return VoltPlanRedirect.searchURL(query: query)
} }
} }
} }

View File

@@ -1,12 +1,6 @@
import SwiftUI import SwiftUI
struct ComponentLibraryItem: Identifiable, Equatable { struct ComponentLibraryItem: Identifiable, Equatable {
struct AffiliateLink: Identifiable, Equatable {
let id: String
let url: URL
let country: String?
}
let id: String let id: String
let name: String let name: String
let translations: [String: String] let translations: [String: String]
@@ -16,7 +10,6 @@ struct ComponentLibraryItem: Identifiable, Equatable {
let dutyCyclePercent: Double? let dutyCyclePercent: Double?
let defaultUtilizationFactorPercent: Double? let defaultUtilizationFactorPercent: Double?
let iconURL: URL? let iconURL: URL?
let affiliateLinks: [AffiliateLink]
var displayVoltage: Double? { var displayVoltage: Double? {
voltageIn ?? voltageOut voltageIn ?? voltageOut
@@ -65,36 +58,10 @@ struct ComponentLibraryItem: Identifiable, Equatable {
return translation(for: locale) return translation(for: locale)
} }
var primaryAffiliateLink: AffiliateLink? {
affiliateLink(matching: Locale.current.region?.identifier)
}
func localizedName(for locale: Locale) -> String { func localizedName(for locale: Locale) -> String {
translation(for: locale) ?? name 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? { private func translation(for locale: Locale) -> String? {
guard !translations.isEmpty else { return nil } guard !translations.isEmpty else { return nil }
@@ -289,7 +256,6 @@ final class ComponentLibraryViewModel: ObservableObject {
page += 1 page += 1
} }
let affiliateLinksByComponent = try await fetchAffiliateLinks(for: allRecords.map(\.id))
let mappedItems = allRecords.map { record in let mappedItems = allRecords.map { record in
ComponentLibraryItem( ComponentLibraryItem(
id: record.id, id: record.id,
@@ -300,8 +266,7 @@ final class ComponentLibraryViewModel: ObservableObject {
watt: record.watt, watt: record.watt,
dutyCyclePercent: record.dutyCycle, dutyCyclePercent: record.dutyCycle,
defaultUtilizationFactorPercent: record.defaultUtilizationFactor, defaultUtilizationFactorPercent: record.defaultUtilizationFactor,
iconURL: iconURL(for: record), iconURL: iconURL(for: record)
affiliateLinks: affiliateLinksByComponent[record.id] ?? []
) )
} }
for item in mappedItems { for item in mappedItems {
@@ -314,110 +279,6 @@ final class ComponentLibraryViewModel: ObservableObject {
return mappedItems 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? { private func iconURL(for record: PocketBaseRecord) -> URL? {
guard let icon = record.icon else { return nil } 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 { 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 { private struct Item: Identifiable {
enum Destination { enum Destination {
case affiliate(URL) case component(String)
case amazonSearch(String) case search(String)
} }
let id: String let id: String
@@ -436,12 +436,12 @@ struct SystemBillOfMaterialsView: View {
} }
private func trackAffiliateTap(item: Item, url: URL) { private func trackAffiliateTap(item: Item, url: URL) {
let isAffiliate: Bool let isComponent: Bool
if case .affiliate = item.destination { isAffiliate = true } else { isAffiliate = false } if case .component = item.destination { isComponent = true } else { isComponent = false }
AnalyticsTracker.log("BOM Item Tapped", properties: [ AnalyticsTracker.log("BOM Item Tapped", properties: [
"item": item.title, "item": item.title,
"category": item.category.rawValue, "category": item.category.rawValue,
"is_affiliate": isAffiliate, "is_component": isComponent,
"domain": url.host ?? "unknown", "domain": url.host ?? "unknown",
"system": systemName, "system": systemName,
]) ])
@@ -585,7 +585,6 @@ struct SystemBillOfMaterialsView: View {
cableShoesDetailFormat, cableShoesDetailFormat,
crossSectionLabel.lowercased() crossSectionLabel.lowercased()
) )
let affiliateURL = load.affiliateURLString.flatMap { URL(string: $0) }
let deviceFallbackFormat = String(localized: "bom.search.device.fallback", defaultValue: "DC device %.0fW %.0fV") let deviceFallbackFormat = String(localized: "bom.search.device.fallback", defaultValue: "DC device %.0fW %.0fV")
let deviceQuery = load.name.isEmpty let deviceQuery = load.name.isEmpty
? String(format: deviceFallbackFormat, calculatedPower, load.voltage) ? 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, title: load.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : load.name,
detail: powerDetail, detail: powerDetail,
iconSystemName: "bolt.fill", iconSystemName: "bolt.fill",
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(deviceQuery), destination: load.componentID.map { .component($0) } ?? .search(deviceQuery),
isPrimaryComponent: true, isPrimaryComponent: true,
components: [component], components: [component],
category: .components, category: .components,
@@ -636,7 +635,7 @@ struct SystemBillOfMaterialsView: View {
title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"), title: String(localized: "bom.item.cable.red", comment: "Title for the red power cable item"),
detail: "", detail: "",
iconSystemName: "bolt.horizontal.circle", iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(redCableQuery), destination: .search(redCableQuery),
isPrimaryComponent: false, isPrimaryComponent: false,
components: [component], components: [component],
category: .cables, category: .cables,
@@ -653,7 +652,7 @@ struct SystemBillOfMaterialsView: View {
title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"), title: String(localized: "bom.item.cable.black", comment: "Title for the black power cable item"),
detail: "", detail: "",
iconSystemName: "bolt.horizontal.circle", iconSystemName: "bolt.horizontal.circle",
destination: .amazonSearch(blackCableQuery), destination: .search(blackCableQuery),
isPrimaryComponent: false, isPrimaryComponent: false,
components: [component], components: [component],
category: .cables, category: .cables,
@@ -670,7 +669,7 @@ struct SystemBillOfMaterialsView: View {
title: String(localized: "bom.item.fuse", comment: "Title for the fuse and holder item"), title: String(localized: "bom.item.fuse", comment: "Title for the fuse and holder item"),
detail: fuseDetail, detail: fuseDetail,
iconSystemName: "bolt.shield", iconSystemName: "bolt.shield",
destination: .amazonSearch(fuseQuery), destination: .search(fuseQuery),
isPrimaryComponent: false, isPrimaryComponent: false,
components: [component], components: [component],
category: .fuses, category: .fuses,
@@ -687,7 +686,7 @@ struct SystemBillOfMaterialsView: View {
title: String(localized: "bom.item.terminals", comment: "Title for the cable terminals item"), title: String(localized: "bom.item.terminals", comment: "Title for the cable terminals item"),
detail: cableShoesDetail, detail: cableShoesDetail,
iconSystemName: "wrench.and.screwdriver", iconSystemName: "wrench.and.screwdriver",
destination: .amazonSearch(terminalQuery), destination: .search(terminalQuery),
isPrimaryComponent: false, isPrimaryComponent: false,
components: [component], components: [component],
category: .accessories, category: .accessories,
@@ -712,7 +711,7 @@ struct SystemBillOfMaterialsView: View {
let voltageQuery = max(1, Int(round(battery.nominalVoltage))) let voltageQuery = max(1, Int(round(battery.nominalVoltage)))
let batterySearchFormat = String(localized: "bom.search.battery", defaultValue: "%dAh %dV %@ battery") let batterySearchFormat = String(localized: "bom.search.battery", defaultValue: "%dAh %dV %@ battery")
let query = String(format: batterySearchFormat, capacityQuery, voltageQuery, battery.chemistry.displayName) 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") let storageKey = Self.storageKey(for: component, itemID: "battery")
return [ 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, title: battery.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : battery.name,
detail: detail, detail: detail,
iconSystemName: battery.iconName, iconSystemName: battery.iconName,
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(query), destination: componentID.map { .component($0) } ?? .search(query),
isPrimaryComponent: true, isPrimaryComponent: true,
components: [component], components: [component],
category: .batteries, category: .batteries,
@@ -746,7 +745,7 @@ struct SystemBillOfMaterialsView: View {
let voltageQuery = max(1, Int(round(charger.outputVoltage))) let voltageQuery = max(1, Int(round(charger.outputVoltage)))
let currentQuery = max(1, Int(round(charger.maxCurrentAmps))) let currentQuery = max(1, Int(round(charger.maxCurrentAmps)))
let query = "\(voltageQuery)V \(currentQuery)A battery charger" 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") let storageKey = Self.storageKey(for: component, itemID: "charger")
return [ 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, title: charger.name.isEmpty ? String(localized: "component.fallback.name", comment: "Fallback name for a component when no name is provided") : charger.name,
detail: detail, detail: detail,
iconSystemName: charger.iconName, iconSystemName: charger.iconName,
destination: affiliateURL.map { .affiliate($0) } ?? .amazonSearch(query), destination: componentID.map { .component($0) } ?? .search(query),
isPrimaryComponent: true, isPrimaryComponent: true,
components: [component], components: [component],
category: .components, category: .components,
@@ -772,22 +771,10 @@ struct SystemBillOfMaterialsView: View {
private func destinationURL(for destination: Item.Destination, component: Component) -> URL? { private func destinationURL(for destination: Item.Destination, component: Component) -> URL? {
switch destination { switch destination {
case .affiliate(let url): case .component(let id):
return url return VoltPlanRedirect.componentURL(id: id)
case .amazonSearch(let query): case .search(let query):
let countryCode = affiliateCountryCode(for: component) ?? Locale.current.region?.identifier return VoltPlanRedirect.searchURL(query: query)
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
} }
} }
@@ -905,8 +892,7 @@ struct SystemBillOfMaterialsView: View {
dailyUsageHours: 0, dailyUsageHours: 0,
system: nil, system: nil,
remoteIconURLString: nil, remoteIconURLString: nil,
affiliateURLString: nil, componentID: nil,
affiliateCountryCode: nil,
bomCompletedItemIDs: [], bomCompletedItemIDs: [],
identifier: UUID().uuidString identifier: UUID().uuidString
) )
@@ -952,8 +938,7 @@ struct SystemBillOfMaterialsView: View {
dailyUsageHours: 0, dailyUsageHours: 0,
system: nil, system: nil,
remoteIconURLString: nil, remoteIconURLString: nil,
affiliateURLString: nil, componentID: nil,
affiliateCountryCode: nil,
bomCompletedItemIDs: ["component", "cable-red"], bomCompletedItemIDs: ["component", "cable-red"],
identifier: UUID().uuidString identifier: UUID().uuidString
) )

View File

@@ -66,7 +66,6 @@ struct SystemComponentsPersistence {
current = 0 current = 0
} }
let affiliateLink = item.primaryAffiliateLink
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100 let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
let dailyUsageHours = item.defaultDailyUsageHours ?? 1 let dailyUsageHours = item.defaultDailyUsageHours ?? 1
@@ -84,8 +83,7 @@ struct SystemComponentsPersistence {
dailyUsageHours: dailyUsageHours, dailyUsageHours: dailyUsageHours,
system: system, system: system,
remoteIconURLString: item.iconURL?.absoluteString, remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString, componentID: item.id
affiliateCountryCode: affiliateLink?.country
) )
context.insert(newLoad) context.insert(newLoad)

View File

@@ -372,7 +372,6 @@ struct SystemsView: View {
current = 0 current = 0
} }
let affiliateLink = item.primaryAffiliateLink
let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100 let dutyCyclePercent = item.normalizedDutyCyclePercent ?? 100
let dailyUsageHours = item.defaultDailyUsageHours ?? 1 let dailyUsageHours = item.defaultDailyUsageHours ?? 1
@@ -390,8 +389,7 @@ struct SystemsView: View {
dailyUsageHours: dailyUsageHours, dailyUsageHours: dailyUsageHours,
system: system, system: system,
remoteIconURLString: item.iconURL?.absoluteString, remoteIconURLString: item.iconURL?.absoluteString,
affiliateURLString: affiliateLink?.url.absoluteString, componentID: item.id
affiliateCountryCode: affiliateLink?.country
) )
modelContext.insert(newLoad) modelContext.insert(newLoad)
@@ -549,8 +547,7 @@ struct SystemsView: View {
isWattMode: false, isWattMode: false,
system: system1, system: system1,
remoteIconURLString: nil, remoteIconURLString: nil,
affiliateURLString: nil, componentID: nil
affiliateCountryCode: nil
) )
let load2 = SavedLoad( let load2 = SavedLoad(
@@ -565,8 +562,7 @@ struct SystemsView: View {
isWattMode: false, isWattMode: false,
system: system1, system: system1,
remoteIconURLString: nil, remoteIconURLString: nil,
affiliateURLString: nil, componentID: nil
affiliateCountryCode: nil
) )
// Sample loads for system 2 // Sample loads for system 2
@@ -582,8 +578,7 @@ struct SystemsView: View {
isWattMode: false, isWattMode: false,
system: system2, system: system2,
remoteIconURLString: nil, remoteIconURLString: nil,
affiliateURLString: nil, componentID: nil
affiliateCountryCode: nil
) )
context.insert(load1) context.insert(load1)

View File

@@ -15,7 +15,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let german = Foundation.Locale(identifier: "de_DE") let german = Foundation.Locale(identifier: "de_DE")
@@ -33,7 +32,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let french = Foundation.Locale(identifier: "fr_FR") let french = Foundation.Locale(identifier: "fr_FR")
@@ -54,7 +52,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let languages = ["fr-FR", "de-DE", "es-ES"] let languages = ["fr-FR", "de-DE", "es-ES"]
@@ -72,7 +69,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let spanishMexico = Foundation.Locale(identifier: "es_MX") let spanishMexico = Foundation.Locale(identifier: "es_MX")
@@ -90,7 +86,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let germanSwitzerland = Foundation.Locale(identifier: "de_CH") let germanSwitzerland = Foundation.Locale(identifier: "de_CH")
@@ -108,7 +103,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
let french = Foundation.Locale(identifier: "fr_FR") let french = Foundation.Locale(identifier: "fr_FR")
@@ -126,7 +120,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: 0, dutyCyclePercent: 0,
defaultUtilizationFactorPercent: nil, defaultUtilizationFactorPercent: nil,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
#expect(item.normalizedDutyCyclePercent == 100) #expect(item.normalizedDutyCyclePercent == 100)
@@ -143,7 +136,6 @@ struct ComponentLibraryItemTests {
dutyCyclePercent: nil, dutyCyclePercent: nil,
defaultUtilizationFactorPercent: 50, defaultUtilizationFactorPercent: 50,
iconURL: nil, iconURL: nil,
affiliateLinks: []
) )
#expect(item.defaultDailyUsageHours == 12) #expect(item.defaultDailyUsageHours == 12)