Skip to content

Commit 11bf8fc

Browse files
authored
Merge pull request #8581 from woocommerce/feat/8558-domain-settings
Domain settings: feature flag, entry point from settings, barebone UI
2 parents 7bd2814 + d3f71c6 commit 11bf8fc

File tree

18 files changed

+452
-1
lines changed

18 files changed

+452
-1
lines changed

Experiments/Experiments/DefaultFeatureFlagService.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
6464
return buildConfig == .localDeveloper || buildConfig == .alpha
6565
case .productsBulkEditing:
6666
return buildConfig == .localDeveloper || buildConfig == .alpha
67+
case .domainSettings:
68+
return buildConfig == .localDeveloper || buildConfig == .alpha
6769
default:
6870
return true
6971
}

Experiments/Experiments/FeatureFlag.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,4 +149,8 @@ public enum FeatureFlag: Int {
149149
/// Bulk editing of status and price in products list
150150
///
151151
case productsBulkEditing
152+
153+
/// Whether to enable domain updates from the settings for a WPCOM site.
154+
///
155+
case domainSettings
152156
}

Networking/Networking/Remote/DomainRemote.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,24 @@ public struct FreeDomainSuggestion: Decodable, Equatable {
4141
}
4242
}
4343

44+
/// Necessary data for a site's domain.
45+
public struct SiteDomain: Equatable {
46+
/// Domain name.
47+
public let name: String
48+
49+
/// Whether the domain is the site's primary domain.
50+
public let isPrimary: Bool
51+
52+
/// The next renewal date, if available.
53+
public let renewalDate: Date?
54+
55+
public init(name: String, isPrimary: Bool, renewalDate: Date? = nil) {
56+
self.name = name
57+
self.isPrimary = isPrimary
58+
self.renewalDate = renewalDate
59+
}
60+
}
61+
4462
// MARK: - Constants
4563
//
4664
private extension DomainRemote {

Networking/Networking/Remote/PaymentRemote.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ public struct WPComPlan: Decodable, Equatable {
6868
}
6969
}
7070

71+
/// Contains necessary data for a site's WPCOM plan.
72+
public struct WPComSitePlan {
73+
/// WPCOM plan of a site.
74+
public let plan: WPComPlan
75+
/// Whether a site has domain credit from the WPCOM plan.
76+
public let hasDomainCredit: Bool
77+
78+
public init(plan: WPComPlan, hasDomainCredit: Bool) {
79+
self.plan = plan
80+
self.hasDomainCredit = hasDomainCredit
81+
}
82+
}
83+
7184
/// Possible error cases from loading a WPCOM plan.
7285
public enum LoadPlanError: Error {
7386
case noMatchingPlan
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import SwiftUI
2+
3+
/// Hosting controller that wraps the `DomainSettingsView` view.
4+
final class DomainSettingsHostingController: UIHostingController<DomainSettingsView> {
5+
init(viewModel: DomainSettingsViewModel) {
6+
super.init(rootView: DomainSettingsView(viewModel: viewModel))
7+
}
8+
9+
required dynamic init?(coder aDecoder: NSCoder) {
10+
fatalError("init(coder:) has not been implemented")
11+
}
12+
13+
override func viewDidLoad() {
14+
super.viewDidLoad()
15+
16+
configureTransparentNavigationBar()
17+
}
18+
}
19+
20+
/// Shows a site's domains with actions to add a domain or redeem a domain credit.
21+
struct DomainSettingsView: View {
22+
@ObservedObject private var viewModel: DomainSettingsViewModel
23+
24+
init(viewModel: DomainSettingsViewModel) {
25+
self.viewModel = viewModel
26+
}
27+
28+
var body: some View {
29+
ScrollView {
30+
VStack(alignment: .leading, spacing: Layout.contentSpacing) {
31+
if let freeDomain = viewModel.freeStagingDomain {
32+
HStack {
33+
FreeStagingDomainView(domain: freeDomain)
34+
Spacer()
35+
}
36+
}
37+
38+
if viewModel.hasDomainCredit {
39+
// TODO: 8558 - domain credit UI with redemption action
40+
}
41+
42+
if viewModel.domains.isNotEmpty {
43+
// TODO: 8558 - show domain list with search domain action
44+
}
45+
}
46+
.padding(Layout.contentPadding)
47+
}
48+
.safeAreaInset(edge: .bottom) {
49+
if viewModel.domains.isEmpty {
50+
VStack {
51+
Divider()
52+
.frame(height: Layout.dividerHeight)
53+
.foregroundColor(Color(.separator))
54+
Button(Localization.searchDomainButton) {
55+
// TODO: 8558 - search domain action
56+
}
57+
.buttonStyle(PrimaryButtonStyle())
58+
.padding(Layout.bottomContentPadding)
59+
}
60+
.background(Color(.systemBackground))
61+
}
62+
}
63+
.navigationBarTitle(Localization.title)
64+
.navigationBarTitleDisplayMode(.inline)
65+
.onAppear {
66+
viewModel.onAppear()
67+
}
68+
}
69+
}
70+
71+
private extension DomainSettingsView {
72+
enum Localization {
73+
static let title = NSLocalizedString("Domain", comment: "Navigation bar title of the domain settings screen.")
74+
static let searchDomainButton = NSLocalizedString(
75+
"Search for a Domain",
76+
comment: "Title of the button on the domain settings screen to search for a domain."
77+
)
78+
}
79+
}
80+
81+
private extension DomainSettingsView {
82+
enum Layout {
83+
static let dividerHeight: CGFloat = 1
84+
static let bottomContentPadding: EdgeInsets = .init(top: 10, leading: 16, bottom: 10, trailing: 16)
85+
static let contentPadding: EdgeInsets = .init(top: 39, leading: 16, bottom: 16, trailing: 16)
86+
static let contentSpacing: CGFloat = 36
87+
}
88+
}
89+
90+
#if DEBUG
91+
92+
import Yosemite
93+
import enum Networking.DotcomError
94+
95+
/// StoresManager that specifically handles actions for `DomainSettingsView` previews.
96+
final class DomainSettingsViewStores: DefaultStoresManager {
97+
private let domainsResult: Result<[SiteDomain], Error>
98+
private let sitePlanResult: Result<WPComSitePlan, Error>
99+
100+
init(domainsResult: Result<[SiteDomain], Error>,
101+
sitePlanResult: Result<WPComSitePlan, Error>) {
102+
self.domainsResult = domainsResult
103+
self.sitePlanResult = sitePlanResult
104+
super.init(sessionManager: ServiceLocator.stores.sessionManager)
105+
}
106+
107+
override func dispatch(_ action: Action) {
108+
if let action = action as? DomainAction {
109+
if case let .loadDomains(_, completion) = action {
110+
completion(domainsResult)
111+
}
112+
} else if let action = action as? PaymentAction {
113+
if case let .loadSiteCurrentPlan(_, completion) = action {
114+
completion(sitePlanResult)
115+
}
116+
}
117+
}
118+
}
119+
120+
struct DomainSettingsView_Previews: PreviewProvider {
121+
static var previews: some View {
122+
Group {
123+
NavigationView {
124+
DomainSettingsView(viewModel:
125+
.init(siteID: 134,
126+
stores: DomainSettingsViewStores(
127+
// There is one free domain and two paid domains.
128+
domainsResult: .success([
129+
.init(name: "free.test", isPrimary: true),
130+
.init(name: "one.test", isPrimary: false, renewalDate: .distantFuture),
131+
.init(name: "duo.test", isPrimary: true, renewalDate: .now)
132+
]),
133+
// The site has domain credit.
134+
sitePlanResult: .success(.init(plan: .init(productID: 0,
135+
name: "",
136+
formattedPrice: ""),
137+
hasDomainCredit: true)))))
138+
}
139+
140+
NavigationView {
141+
DomainSettingsView(viewModel:
142+
.init(siteID: 134,
143+
stores: DomainSettingsViewStores(
144+
// There is one free domain and no other paid domains.
145+
domainsResult: .success([
146+
.init(name: "free.test", isPrimary: true)
147+
]),
148+
sitePlanResult: .success(.init(plan: .init(productID: 0,
149+
name: "",
150+
formattedPrice: ""),
151+
hasDomainCredit: true)))))
152+
}
153+
}
154+
}
155+
}
156+
157+
#endif
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import Foundation
2+
import Yosemite
3+
4+
/// View model for `DomainSettingsView`.
5+
final class DomainSettingsViewModel: ObservableObject {
6+
struct Domain {
7+
/// Whether the domain is the site's primary domain.
8+
let isPrimary: Bool
9+
10+
/// The address of the domain.
11+
let name: String
12+
13+
// The next renewal date.
14+
let autoRenewalDate: Date?
15+
}
16+
17+
struct FreeStagingDomain {
18+
/// Whether the domain is the site's primary domain.
19+
let isPrimary: Bool
20+
21+
/// The address of the domain.
22+
let name: String
23+
}
24+
25+
@Published private(set) var hasDomainCredit: Bool = false
26+
@Published private(set) var domains: [Domain] = []
27+
@Published private(set) var freeStagingDomain: FreeStagingDomain?
28+
29+
private let siteID: Int64
30+
private let stores: StoresManager
31+
32+
init(siteID: Int64, stores: StoresManager = ServiceLocator.stores) {
33+
self.siteID = siteID
34+
self.stores = stores
35+
}
36+
37+
func onAppear() {
38+
stores.dispatch(DomainAction.loadDomains(siteID: siteID) { [weak self] result in
39+
self?.handleDomainsResult(result)
40+
})
41+
42+
stores.dispatch(PaymentAction.loadSiteCurrentPlan(siteID: siteID) { [weak self] result in
43+
self?.handleSiteCurrentPlanResult(result)
44+
})
45+
}
46+
}
47+
48+
private extension DomainSettingsViewModel {
49+
func handleDomainsResult(_ result: Result<[SiteDomain], Error>) {
50+
switch result {
51+
case .success(let domains):
52+
let stagingDomain = domains.first(where: { $0.renewalDate == nil })
53+
freeStagingDomain = stagingDomain
54+
.map { FreeStagingDomain(isPrimary: $0.isPrimary, name: $0.name) }
55+
self.domains = domains.filter { $0 != stagingDomain }
56+
.map { Domain(isPrimary: $0.isPrimary, name: $0.name, autoRenewalDate: $0.renewalDate) }
57+
case .failure(let error):
58+
DDLogError("⛔️ Error retrieving domains for siteID \(siteID): \(error)")
59+
}
60+
}
61+
62+
func handleSiteCurrentPlanResult(_ result: Result<WPComSitePlan, Error>) {
63+
switch result {
64+
case .success(let sitePlan):
65+
hasDomainCredit = sitePlan.hasDomainCredit
66+
case .failure(let error):
67+
DDLogError("⛔️ Error retrieving site plan for siteID \(siteID): \(error)")
68+
}
69+
}
70+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import SwiftUI
2+
3+
/// Shows a site's free staging domain, with an optional badge if the domain is the primary domain.
4+
struct FreeStagingDomainView: View {
5+
let domain: DomainSettingsViewModel.FreeStagingDomain
6+
7+
var body: some View {
8+
VStack(alignment: .leading, spacing: Layout.contentSpacing) {
9+
VStack(alignment: .leading, spacing: 0) {
10+
Text(Localization.freeDomainTitle)
11+
Text(domain.name)
12+
.bold()
13+
}
14+
if domain.isPrimary {
15+
// TODO: 8558 - refactor to reuse `BadgeView`
16+
Text(Localization.primaryDomainNotice)
17+
.foregroundColor(Color(.textBrand))
18+
.padding(.leading, Layout.horizontalPadding)
19+
.padding(.trailing, Layout.horizontalPadding)
20+
.padding(.top, Layout.verticalPadding)
21+
.padding(.bottom, Layout.verticalPadding)
22+
.background(RoundedRectangle(cornerRadius: Layout.cornerRadius)
23+
.fill(Color(.withColorStudio(.wooCommercePurple, shade: .shade0))))
24+
.font(.system(size: 12, weight: .bold))
25+
}
26+
}
27+
}
28+
}
29+
30+
private extension FreeStagingDomainView {
31+
enum Localization {
32+
static let freeDomainTitle = NSLocalizedString(
33+
"Your free store address",
34+
comment: "Title of the free domain view."
35+
)
36+
static let primaryDomainNotice = NSLocalizedString(
37+
"Primary site address",
38+
comment: "Title for a free domain if the domain is the primary site address."
39+
)
40+
}
41+
}
42+
43+
private extension FreeStagingDomainView {
44+
enum Layout {
45+
static let horizontalPadding: CGFloat = 6
46+
static let verticalPadding: CGFloat = 4
47+
static let cornerRadius: CGFloat = 8
48+
static let contentSpacing: CGFloat = 8
49+
}
50+
}
51+
52+
struct FreeStagingDomainView_Previews: PreviewProvider {
53+
static var previews: some View {
54+
VStack {
55+
FreeStagingDomainView(domain: .init(isPrimary: true, name: "go.trees"))
56+
FreeStagingDomainView(domain: .init(isPrimary: false, name: "go.trees"))
57+
}
58+
}
59+
}

0 commit comments

Comments
 (0)