Skip to content

Commit 4a22eb5

Browse files
authored
Merge pull request #7886 from woocommerce/feat/7879-store-creation-webview-feature-flag
Store creation MVP: site picker entry point behind a feature flag
2 parents 9cb0d76 + 1b9d162 commit 4a22eb5

File tree

10 files changed

+304
-7
lines changed

10 files changed

+304
-7
lines changed

Experiments/Experiments/DefaultFeatureFlagService.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
3939
return buildConfig == .localDeveloper || buildConfig == .alpha
4040
case .inAppPurchases:
4141
return buildConfig == .localDeveloper || buildConfig == .alpha
42+
case .storeCreationMVP:
43+
return buildConfig == .localDeveloper || buildConfig == .alpha
4244
default:
4345
return true
4446
}

Experiments/Experiments/FeatureFlag.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,8 @@ public enum FeatureFlag: Int {
8181
/// Enables In-app purchases for buying Hosted WooCommerce plans
8282
///
8383
case inAppPurchases
84+
85+
/// Store creation MVP.
86+
///
87+
case storeCreationMVP
8488
}

WooCommerce/Classes/Authentication/AuthenticatedWebViewController.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ final class AuthenticatedWebViewController: UIViewController {
1414
let webView = WKWebView(frame: .zero)
1515
webView.translatesAutoresizingMaskIntoConstraints = false
1616
webView.navigationDelegate = self
17+
webView.uiDelegate = self
1718
return webView
1819
}()
1920

@@ -156,3 +157,17 @@ extension AuthenticatedWebViewController: WKNavigationDelegate {
156157
progressBar.setProgress(0, animated: false)
157158
}
158159
}
160+
161+
extension AuthenticatedWebViewController: WKUIDelegate {
162+
func webView(_ webView: WKWebView,
163+
createWebViewWith configuration: WKWebViewConfiguration,
164+
for navigationAction: WKNavigationAction,
165+
windowFeatures: WKWindowFeatures) -> WKWebView? {
166+
// Allows `target=_blank` links by opening them in the same view, otherwise tapping on these links is no-op.
167+
// Reference: https://stackoverflow.com/a/25853806/9185596
168+
if navigationAction.targetFrame == nil {
169+
webView.load(navigationAction.request)
170+
}
171+
return nil
172+
}
173+
}

WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.swift

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ final class StorePickerViewController: UIViewController {
102102
}
103103
}
104104

105+
/// Create store button.
106+
@IBOutlet private weak var createStoreButton: FancyAnimatedButton!
107+
105108
/// New To Woo button
106109
///
107110
@IBOutlet var newToWooButton: UIButton! {
@@ -157,9 +160,13 @@ final class StorePickerViewController: UIViewController {
157160
self?.restartAuthentication()
158161
}
159162

163+
@Published private var possibleSiteURLsFromStoreCreation: Set<String> = []
164+
private var possibleSiteURLsFromStoreCreationSubscription: AnyCancellable?
165+
160166
private let appleIDCredentialChecker: AppleIDCredentialCheckerProtocol
161167
private let stores: StoresManager
162168
private let featureFlagService: FeatureFlagService
169+
private let isStoreCreationEnabled: Bool
163170

164171
init(configuration: StorePickerConfiguration,
165172
appleIDCredentialChecker: AppleIDCredentialCheckerProtocol = AppleIDCredentialChecker(),
@@ -170,6 +177,7 @@ final class StorePickerViewController: UIViewController {
170177
self.stores = stores
171178
self.featureFlagService = featureFlagService
172179
self.viewModel = StorePickerViewModel(configuration: configuration)
180+
self.isStoreCreationEnabled = featureFlagService.isFeatureFlagEnabled(.storeCreationMVP)
173181
super.init(nibName: Self.nibName, bundle: nil)
174182
}
175183

@@ -185,8 +193,10 @@ final class StorePickerViewController: UIViewController {
185193
setupMainView()
186194
setupAccountHeader()
187195
setupTableView()
196+
setupCreateStoreButton()
188197
refreshResults()
189198
observeStateChange()
199+
observeSiteURLsFromStoreCreation()
190200

191201
switch configuration {
192202
case .login:
@@ -276,6 +286,17 @@ private extension StorePickerViewController {
276286
}
277287
}
278288

289+
func setupCreateStoreButton() {
290+
createStoreButton.isHidden = isStoreCreationEnabled == false
291+
createStoreButton.isPrimary = false
292+
createStoreButton.backgroundColor = .clear
293+
createStoreButton.titleFont = StyleManager.actionButtonTitleFont
294+
createStoreButton.setTitle(Localization.createStore, for: .normal)
295+
createStoreButton.on(.touchUpInside) { [weak self] _ in
296+
self?.createStoreButtonPressed()
297+
}
298+
}
299+
279300
func refreshResults() {
280301
viewModel.refreshSites(currentlySelectedSiteID: currentlySelectedSite?.siteID)
281302
viewModel.trackScreenView()
@@ -296,6 +317,28 @@ private extension StorePickerViewController {
296317
func presentHelp() {
297318
ServiceLocator.authenticationManager.presentSupport(from: self, screen: .storePicker)
298319
}
320+
321+
func observeSiteURLsFromStoreCreation() {
322+
possibleSiteURLsFromStoreCreationSubscription = $possibleSiteURLsFromStoreCreation
323+
.filter { $0.isEmpty == false }
324+
.removeDuplicates()
325+
// There are usually three URLs in the webview that return a site URL - two with `*.wordpress.com` and the other the final URL.
326+
.debounce(for: .seconds(5), scheduler: DispatchQueue.main)
327+
.asyncMap { [weak self] possibleSiteURLs -> Site? in
328+
// Waits for 5 seconds before syncing sites every time.
329+
try await Task.sleep(nanoseconds: 5_000_000_000)
330+
return try await self?.syncSites(forSiteThatMatchesPossibleURLs: possibleSiteURLs)
331+
}
332+
// Retries 10 times with 5 seconds pause in between to wait for the newly created site to be available as a Jetpack site
333+
// in the WPCOM `/me/sites` response.
334+
.retry(10)
335+
.replaceError(with: nil)
336+
.receive(on: DispatchQueue.main)
337+
.sink { [weak self] site in
338+
guard let self, let site else { return }
339+
self.continueWithSelectedSite(site: site)
340+
}
341+
}
299342
}
300343

301344

@@ -540,7 +583,7 @@ extension StorePickerViewController: UIViewControllerTransitioningDelegate {
540583

541584
// MARK: - Action Handlers
542585
//
543-
extension StorePickerViewController {
586+
private extension StorePickerViewController {
544587

545588
/// Proceeds with the Login Flow.
546589
///
@@ -583,6 +626,67 @@ extension StorePickerViewController {
583626
@IBAction func secondaryActionWasPressed() {
584627
restartAuthentication()
585628
}
629+
630+
func createStoreButtonPressed() {
631+
// TODO-7879: analytics
632+
633+
let viewModel = StoreCreationWebViewModel { [weak self] result in
634+
self?.handleStoreCreationResult(result)
635+
}
636+
possibleSiteURLsFromStoreCreation = []
637+
let webViewController = AuthenticatedWebViewController(viewModel: viewModel)
638+
webViewController.addCloseNavigationBarButton(target: self, action: #selector(handleStoreCreationCloseAction))
639+
let navigationController = WooNavigationController(rootViewController: webViewController)
640+
// Disables interactive dismissal of the store creation modal.
641+
navigationController.isModalInPresentation = true
642+
present(navigationController, animated: true)
643+
}
644+
645+
@objc func handleStoreCreationCloseAction() {
646+
// TODO-7879: show a confirmation alert before closing the store creation view
647+
// TODO-7879: analytics
648+
dismiss(animated: true)
649+
}
650+
651+
func handleStoreCreationResult(_ result: Result<String, Error>) {
652+
switch result {
653+
case .success(let siteURL):
654+
// TODO-7879: analytics
655+
656+
// There could be multiple site URLs from the completion URL in the webview, and only one
657+
// of them matches the final site URL from WPCOM `/me/sites` endpoint.
658+
possibleSiteURLsFromStoreCreation.insert(siteURL)
659+
case .failure(let error):
660+
// TODO-7879: analytics
661+
DDLogError("Store creation error: \(error)")
662+
}
663+
}
664+
665+
@MainActor
666+
func syncSites(forSiteThatMatchesPossibleURLs possibleURLs: Set<String>) async throws -> Site {
667+
return try await withCheckedThrowingContinuation { [weak self] continuation in
668+
viewModel.refreshSites(currentlySelectedSiteID: nil) { [weak self] in
669+
guard let self else { return }
670+
// The newly created site often has `isJetpackThePluginInstalled=false` initially,
671+
// which results in a JCP site.
672+
// In this case, we want to retry sites syncing.
673+
guard let site = self.viewModel.site(thatMatchesPossibleURLs: possibleURLs) else {
674+
return continuation.resume(throwing: StoreCreationError.newSiteUnavailable)
675+
}
676+
guard site.isJetpackConnected && site.isJetpackThePluginInstalled else {
677+
return continuation.resume(throwing: StoreCreationError.newSiteIsNotJetpackSite)
678+
}
679+
continuation.resume(returning: site)
680+
}
681+
}
682+
}
683+
684+
func continueWithSelectedSite(site: Site) {
685+
currentlySelectedSite = site
686+
dismiss(animated: true) { [weak self] in
687+
self?.checkRoleEligibility(for: site)
688+
}
689+
}
586690
}
587691

588692

@@ -759,6 +863,8 @@ private extension StorePickerViewController {
759863
comment: "Button to input a site address in store picker when there are no stores found")
760864
static let newToWooCommerce = NSLocalizedString("New to WooCommerce?",
761865
comment: "Title of button on the site picker screen for users who are new to WooCommerce.")
866+
static let createStore = NSLocalizedString("Create a new store",
867+
comment: "Button to create a new store from the store picker")
762868
}
763869
}
764870

WooCommerce/Classes/Authentication/Epilogue/StorePickerViewController.xib

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21179.7" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
2+
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="21225" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
33
<device id="retina6_5" orientation="portrait" appearance="light"/>
44
<dependencies>
55
<deployment identifier="iOS"/>
6-
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21169.4"/>
6+
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21207"/>
77
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
88
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
99
</dependencies>
1010
<objects>
1111
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner" customClass="StorePickerViewController" customModule="WooCommerce" customModuleProvider="target">
1212
<connections>
1313
<outlet property="actionButton" destination="0KD-hY-YaS" id="mJR-Pi-S0z"/>
14+
<outlet property="createStoreButton" destination="JzA-jw-LX7" id="bKC-9a-pp5"/>
1415
<outlet property="enterSiteAddressButton" destination="mXN-0a-h6C" id="AR4-GV-obp"/>
1516
<outlet property="newToWooButton" destination="X1y-bd-PXx" id="Rhc-sE-XIZ"/>
1617
<outlet property="secondaryActionButton" destination="Slo-h4-7qY" id="YFU-eL-sGS"/>
@@ -24,18 +25,18 @@
2425
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
2526
<subviews>
2627
<tableView clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" contentInsetAdjustmentBehavior="never" style="grouped" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="18" sectionFooterHeight="18" translatesAutoresizingMaskIntoConstraints="NO" id="oVs-XS-592" userLabel="Sites Table View">
27-
<rect key="frame" x="0.0" y="44" width="414" height="533.66666666666663"/>
28+
<rect key="frame" x="0.0" y="44.000000000000028" width="414" height="463.66666666666674"/>
2829
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
2930
<connections>
3031
<outlet property="dataSource" destination="-1" id="m9y-Av-zfJ"/>
3132
<outlet property="delegate" destination="-1" id="srI-Ru-BMi"/>
3233
</connections>
3334
</tableView>
3435
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="H5J-Qa-DXp">
35-
<rect key="frame" x="0.0" y="577.66666666666663" width="414" height="318.33333333333337"/>
36+
<rect key="frame" x="0.0" y="507.66666666666674" width="414" height="388.33333333333326"/>
3637
<subviews>
3738
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="20" translatesAutoresizingMaskIntoConstraints="NO" id="0a6-Ee-KF8">
38-
<rect key="frame" x="20" y="20.000000000000014" width="374" height="244.33333333333337"/>
39+
<rect key="frame" x="20" y="19.999999999999943" width="374" height="314.33333333333331"/>
3940
<subviews>
4041
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="X1y-bd-PXx">
4142
<rect key="frame" x="0.0" y="0.0" width="374" height="34.333333333333336"/>
@@ -91,6 +92,18 @@
9192
<action selector="secondaryActionWasPressed" destination="-1" eventType="touchUpInside" id="Dbs-az-84p"/>
9293
</connections>
9394
</button>
95+
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="JzA-jw-LX7" userLabel="Create Store Button" customClass="FancyAnimatedButton" customModule="WooCommerce" customModuleProvider="target">
96+
<rect key="frame" x="0.0" y="264.33333333333337" width="374" height="50"/>
97+
<color key="backgroundColor" red="0.58823529409999997" green="0.34509803919999998" blue="0.54117647059999996" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
98+
<constraints>
99+
<constraint firstAttribute="height" constant="50" id="wwz-3k-mxi"/>
100+
</constraints>
101+
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="17"/>
102+
<state key="normal" title="Create a new store"/>
103+
<userDefinedRuntimeAttributes>
104+
<userDefinedRuntimeAttribute type="boolean" keyPath="isPrimary" value="YES"/>
105+
</userDefinedRuntimeAttributes>
106+
</button>
94107
</subviews>
95108
</stackView>
96109
</subviews>

WooCommerce/Classes/Authentication/Epilogue/StorePickerViewModel.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,12 @@ final class StorePickerViewModel {
4848
])
4949
}
5050

51-
func refreshSites(currentlySelectedSiteID: Int64?) {
51+
func refreshSites(currentlySelectedSiteID: Int64?, completion: (() -> Void)? = nil) {
5252
refetchSitesAndUpdateState()
5353

5454
synchronizeSites(selectedSiteID: currentlySelectedSiteID) { [weak self] _ in
5555
self?.refetchSitesAndUpdateState()
56+
completion?()
5657
}
5758
}
5859

@@ -159,6 +160,20 @@ extension StorePickerViewModel {
159160
}
160161
return resultsController.safeObject(at: indexPath)
161162
}
163+
164+
/// Returns the site that matches the given URL.
165+
///
166+
func site(thatMatchesPossibleURLs possibleURLs: Set<String>) -> Site? {
167+
guard resultsController.numberOfObjects > 0 else {
168+
return nil
169+
}
170+
return resultsController.fetchedObjects.first(where: { site in
171+
guard let siteURL = URL(string: site.url)?.host else {
172+
return false
173+
}
174+
return possibleURLs.contains(siteURL)
175+
})
176+
}
162177
}
163178

164179
private extension StorePickerViewModel {

0 commit comments

Comments
 (0)