diff --git a/Cable/AmazonAffiliate.swift b/Cable/AmazonAffiliate.swift index 016e23c..58669a3 100644 --- a/Cable/AmazonAffiliate.swift +++ b/Cable/AmazonAffiliate.swift @@ -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 } } diff --git a/Cable/Batteries/SavedBattery.swift b/Cable/Batteries/SavedBattery.swift index 095d3c3..786052e 100644 --- a/Cable/Batteries/SavedBattery.swift +++ b/Cable/Batteries/SavedBattery.swift @@ -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 } diff --git a/Cable/Chargers/SavedCharger.swift b/Cable/Chargers/SavedCharger.swift index ce18bd8..3556d30 100644 --- a/Cable/Chargers/SavedCharger.swift +++ b/Cable/Chargers/SavedCharger.swift @@ -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 diff --git a/Cable/Loads/CableCalculator.swift b/Cable/Loads/CableCalculator.swift index ef672e1..bd9ecf8 100644 --- a/Cable/Loads/CableCalculator.swift +++ b/Cable/Loads/CableCalculator.swift @@ -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 } diff --git a/Cable/Loads/CalculatorView.swift b/Cable/Loads/CalculatorView.swift index 1aaec9a..1fbefce 100644 --- a/Cable/Loads/CalculatorView.swift +++ b/Cable/Loads/CalculatorView.swift @@ -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) } } } diff --git a/Cable/Loads/ComponentLibraryView.swift b/Cable/Loads/ComponentLibraryView.swift index 75bc8ed..661e59d 100644 --- a/Cable/Loads/ComponentLibraryView.swift +++ b/Cable/Loads/ComponentLibraryView.swift @@ -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.. 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 { diff --git a/Cable/ScreenshotPreviews.swift b/Cable/ScreenshotPreviews.swift new file mode 100644 index 0000000..51a8d8f --- /dev/null +++ b/Cable/ScreenshotPreviews.swift @@ -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(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")) +} diff --git a/Cable/Systems/SystemBillOfMaterialsView.swift b/Cable/Systems/SystemBillOfMaterialsView.swift index cd6e0d3..c7ae9f1 100644 --- a/Cable/Systems/SystemBillOfMaterialsView.swift +++ b/Cable/Systems/SystemBillOfMaterialsView.swift @@ -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 ) diff --git a/Cable/Systems/SystemComponentsPersistence.swift b/Cable/Systems/SystemComponentsPersistence.swift index 2a340dd..14dd082 100644 --- a/Cable/Systems/SystemComponentsPersistence.swift +++ b/Cable/Systems/SystemComponentsPersistence.swift @@ -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) diff --git a/Cable/Systems/SystemsView.swift b/Cable/Systems/SystemsView.swift index eb3b34d..e1532bd 100644 --- a/Cable/Systems/SystemsView.swift +++ b/Cable/Systems/SystemsView.swift @@ -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) diff --git a/CableTests/ComponentLibraryItemTests.swift b/CableTests/ComponentLibraryItemTests.swift index 2c28460..597f123 100644 --- a/CableTests/ComponentLibraryItemTests.swift +++ b/CableTests/ComponentLibraryItemTests.swift @@ -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)