Repository navigation
feat(ios): App Store review ask and profile row - #7364
rebelchris wants to merge 4 commits into
Conversation
Customer.io schedules the ask through a new `app_store_review` marketing CTA; the sheet opens when the reader leaves a post, posts `review-request` to the iOS bridge two seconds later, and its button opens the App Store's Write a Review screen. The profile menu gets a permanent "Show daily.dev some love" row on iOS 1.26 and later. Both sit behind the `app_store_review` flag. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
rebelchris
left a comment
There was a problem hiding this comment.
Two blockers: the profile row doesn't show up on iPhone, and the ask sheet needs a decision on App Store guideline 5.6.1. Three non-blocking notes are inline. The bridge, flag, logging and tests look solid otherwise. CI is green. I read the code but didn't run the suites locally or test on a device.
Reviewed by AI.
| // tap, since it may show nothing. The row opens the App Store's | ||
| // write-a-review screen instead, which never spends the yearly prompts. | ||
| const rateApp: ProfileSectionItemProps[] = | ||
| canReview && isReviewEnabled |
There was a problem hiding this comment.
Blocking: iPhone users never see this row. ResourceSection only renders inside ProfileMenu, and the only thing that opens that is ProfileButton, which is mounted as hidden laptop:flex (layout/HeaderButtons.tsx). The iOS app runs at phone width, where the profile surface is the phone shell's /you page (shell/YouPage.tsx). That page has its own Changelog row in the Help drawer (~L491). So with a 1.26 bridge on an iPhone, the gate here passes but the row is never mounted. The spec renders ResourceSection directly, so it doesn't catch this.
Please add the row to YouPage next to Changelog, with the same iOSSupportsReview() and flag gate. That's where iOS users will see it. Whether to keep it in the desktop dropdown too is a separate call: it only shows there on an iPad at laptop width.
Reviewed by AI.
There was a problem hiding this comment.
Done. The row now lives in the phone shell's You page, in the Help drawer under Changelog, behind the same bridge and flag gate (useRateAppRow). The dropdown keeps it for iPads at laptop width.
| size={ButtonSize.Large} | ||
| icon={<AppleIcon />} | ||
| aria-label={appStoreReviewCopy.ctaLabel} | ||
| onClick={onRate} |
There was a problem hiding this comment.
Blocking (product/compliance question): App Review Guideline 5.6.1 says to "use the provided API to prompt users to review your app … we will disallow custom review prompts." This sheet is a custom prompt. It has our own copy and a primary CTA that deep-links to Write a Review, and it's shown without the user asking for it. Apple's own sample code covers a persistent Write a Review link that the user taps themselves, like the profile row. It doesn't cover a scheduled sheet. Because the sheet is remote web content, App Review may never see it, but the risk to the app still applies.
Has someone signed off on this? If not, one option that stays inside the rules is to have the campaign trigger only requestIOSReview() at the same natural pause, with no sheet, and keep Write a Review on the user-initiated profile row. If the sheet stays, please note the decision in the PR.
Reviewed by AI.
There was a problem hiding this comment.
Not something I can sign off. I added a Guideline 5.6.1 section to the PR description with the reasoning the design was built on (no stars, Apple's own pop-up only, Write a Review deep link everywhere) and the strictly in-rules fallback: a campaign that only posts review-request with no sheet, plus the user-initiated row. Needs a product decision before the first campaign goes live.
| // Scheduled when the sheet opens and never cancelled: closing it early | ||
| // must not turn the pop-up into something only people who stay get, which | ||
| // would be the review gating Apple rejects. | ||
| setTimeout(() => { |
There was a problem hiding this comment.
Non-blocking: if the reader taps Rate daily.dev within the 2s, we post review-open-store and then review-request right after. Someone who is already writing a review then gets the rating pop-up too, when they come back or over the App Store hand-off. It also spends one of Apple's three yearly prompts on them.
Skipping the request once the CTA was tapped isn't review gating, because the user has already chosen to review. Not now and dismiss would still get it. A ref set in onRate and checked in the timeout would be enough.
Reviewed by AI.
There was a problem hiding this comment.
Done. onRate sets a ref and the timeout skips review-request once Rate was tapped. Not now and dismiss still get it.
| !user || | ||
| !isEnabled || | ||
| !router?.events || | ||
| !iOSSupportsReview() |
There was a problem hiding this comment.
Non-blocking: this hook runs before BootPopups reads useIsBootPopupsPaused() and doesn't check it. So leaving a post for a page that pauses pop-ups, such as the Startups setup on a company squad, still opens the sheet on top of it. Please add isPaused to the guard, either passed in from BootPopups or read here.
Reviewed by AI.
There was a problem hiding this comment.
Done. The hook reads useIsBootPopupsPaused() through a ref and skips the ask while a page pauses pop-ups.
| return () => { | ||
| router.events.off('routeChangeComplete', onRouteChange); | ||
| }; | ||
| }, [campaignId, isEnabled, openModal, router, user]); |
There was a problem hiding this comment.
Non-blocking: router from useRouter() is a new public instance on every route render (makePublicRouterInstance in Next's AppContainer). That means this effect unsubscribes, resubscribes and resets wasOnPost on every navigation, which the comment above was meant to avoid. It works today only because routeChangeComplete is emitted from a microtask after the transition commit, before passive effects flush. Any ordering change in Next would reset wasOnPost to the destination first, and the ask would silently never open.
useLogPageView subscribes once for this reason. Depending on router.events, which is the stable emitter, and reading asPath through a ref would remove that dependency on ordering.
Small related point: feed-modal opens push with as = /posts/:id (usePostModalNavigation), so routeChangeComplete never sees ?pmid=. The pmid branch and its /?pmid=abc123 test don't model a real URL. The /posts/ check already covers the modal.
Reviewed by AI.
There was a problem hiding this comment.
Done. The effect depends on router.events only, with asPath read through a ref, and a test rerenders with a new router object to check the listener survives. The pmid branch and its test are gone.
Adds the `app_store_review_plus` variant with the Plus thank-you sheet, moves the rate row onto the phone shell's You page where iPhones reach it, skips the rating pop-up once Rate was tapped, holds the ask on pages that pause pop-ups, and subscribes once to the stable router emitter. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
rebelchris
left a comment
There was a problem hiding this comment.
Re-review of 3605aba. These four earlier findings are fixed: the row now appears on the phone /you page, Rate tapped before the timer no longer triggers the pop-up, the hook respects paused pop-ups, and it subscribes to the stable router emitter. The Guideline 5.6.1 thread is still open. It needs a product decision, and the new Plus sheet adds a second custom ask (AskCard) under the same exposure.
This commit also adds a new surface, the Plus renewal thank-you, about 550 lines and 17 files, after the first review. The original description listed Plus renewal as out of scope. It has one new blocker, inline. Splitting it into its own PR would let the review fixes land on their own.
CI was still running on this head when I checked. I read the code but didn't run the suites or test on a device.
Reviewed by AI.
| icon={<GiftIcon />} | ||
| onClick={onGift} | ||
| > | ||
| {plusRenewalCopy.gift} |
There was a problem hiding this comment.
Blocking: this sheet only opens in the iOS app, and gifting isn't available there. On iOS the payment context comes from StoreKitSubProvider, which hard-codes giftOneYear: undefined (contexts/payment/StoreKit.tsx). PlusInfo hides its gift button for that reason (showBuyAsAGiftButton needs giftOneYear).
The primary CTA here opens GiftPlusModal anyway. On iOS that renders "Gift one year of daily.dev Plus for ." and an unpriced "Gift & Pay" linking to /plus?gift=…, where no gift product exists. Even if it worked, a non-IAP purchase path inside the iOS app is its own App Review problem (3.1.1).
Please drop the gift CTA on this surface. That would make the ask card or "Back to my feed" the primary action. If gifting on iOS is wanted, it needs StoreKit support first.
Reviewed by AI.
There was a problem hiding this comment.
Done. Gifting is gone from the Plus sheet since StoreKit has no gift product. The sheet now asks directly: Plus tile and badge, thank-you copy, a primary Rate daily.dev that opens Write a Review, and Back to my feed. The ask card went with it, so there is one ask on the sheet, not two.
| event_name: LogEvent.GiftSubscription, | ||
| target_id: TargetId.AppStoreReviewPlus, | ||
| }); | ||
| onDismiss(event); |
There was a problem hiding this comment.
Non-blocking: onGift goes through onDismiss, so a Gift tap also logs dismiss promotion on app_store_review. Dismiss counts would then include people who engaged with the sheet. If the gift CTA stays, clear the CTA and close without logging the dismiss. The GiftSubscription event already records the action.
Reviewed by AI.
There was a problem hiding this comment.
Moot now that the gift button is gone; the only closes left are Rate (logs click) and Back to my feed or dismiss (logs dismiss promotion).
| const plainCta = getMarketingCta(MarketingCtaVariant.AppStoreReview); | ||
| // The Plus thank-you only makes sense for a member; otherwise it waits in | ||
| // boot until the campaign is corrected. | ||
| const plusCta = user?.isPlus |
There was a problem hiding this comment.
Non-blocking: Boot carries a single marketingCta. So when the Plus variant is targeted at someone who isn't Plus (or who lapsed before seeing it), it stays there and blocks every other marketing CTA for that user until Customer.io replaces it, with no impression or clear. Either clear it here, or note in the Customer.io setup that the app_store_review_plus segment must be limited to Plus members.
Reviewed by AI.
There was a problem hiding this comment.
Done. The hook clears an app_store_review_plus CTA that reaches someone without Plus (with a test), so it cannot hold the boot slot. The PR description also tells the campaign setup to limit that segment to members.
Gifting Plus is not available inside the iOS app, so the Plus renewal sheet asks for the rating directly instead of carrying a gift button and an ask card. A Plus ask that reaches someone without Plus is cleared so it cannot hold the single boot marketing CTA slot. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
On splitting the Plus sheet out: it stays in this PR on purpose, the ask was to ship the Plus renewal frontend here on the assumption a Customer.io campaign triggers it. The description is updated. The Guideline 5.6.1 thread stays open for a product call. |
capJavert
left a comment
There was a problem hiding this comment.
Requesting changes for one missing piece: the streak milestone ask has to go inside the existing streak popup, with no Customer.io involvement. The other review asks stay on the marketing CTA flow as built.
Right now nothing in this PR touches the streak flow. A streak ask could only reach people as a CIO app_store_review CTA, which shows as a separate sheet the next time they leave a post. We want it on the milestone itself, while the streak is still on screen (as in the #7329 build spec).
What's needed:
NewStreakModal.tsx: render theAskCard(headline, short body, Secondary Small "Rate daily.dev" withAppleIcon) under the milestone copy and above the "Never show this again"Checkbox.- When it shows:
- In the iOS app only, when
iOSSupportsReview()is true. - Behind the
app_store_reviewflag, or a dedicated streak flag if you'd rather tune it separately. - Only for a 30-day or later milestone, per the spec.
- Not in a session that had errors.
- Otherwise the modal renders exactly as today.
- In the iOS app only, when
- Behaviour: the same as the CTA sheet.
- When the card shows, schedule
requestIOSReview()about 1.5s later and never cancel it on close (no review gating). - Skip that request if the reader already tapped Rate.
- The button calls
openIOSWriteReview(). - Reuse
useAppStoreReviewAsk, but it currently requires acampaignIdand always callsclearMarketingCta. Make the CTA part optional, or split out the timer and logging, so a surface without a campaign can use it.
- When the card shows, schedule
- Cooldown: there's no campaign to control frequency, so store the last-ask time on the device (the spec's
review_promptkey inusePersistentContext). Show the card at most once per 120 days, matching the native cooldown, so it doesn't appear on every milestone. - Logging: reuse the
app_store_reviewtarget type with something liketarget_id: 'streak_milestone'(asprofile_menuis used for the row), so it reads apart from campaign asks. - Tests: add a case in
NewStreakModal/StreakMilestonePopupspecs for "shows the card on iOS with the bridge at 30+ days" and "unchanged elsewhere".
Reviewed by AI.
Customer.io cannot tell iOS app versions apart, so an app older than 1.26 can receive an ask it can never show. Boot carries one marketing CTA, so it would hold that slot and block every other popover. Clear it instead, like the Plus thank-you for someone without Plus.
What
Ships the frontend for the App Store review asks explored in #7329. Who gets asked and when is Customer.io's job (Ante); this PR gives it one surface to trigger and adds the permanent profile row.
lib/ios.ts):ReviewRequestandReviewOpenStorehandlers, withiOSSupportsReview(),requestIOSReview()andopenIOSWriteReview(). Both handlers shipped in iOS 1.26 (dailydotdev/ios#29); older apps show nothing.AppStoreReviewModal): a newapp_store_reviewmarketing CTA variant. Logo (or the campaign'simage), headline, body, a primary "Rate daily.dev" that opens the App Store's Write a Review screen, and "Not now". Copy falls back to the storybook defaults when the campaign'stitle,descriptionorctaTextare empty. About 2s after it opens it postsreview-requestso Apple's pop-up lands over it; that timer is never cancelled, so closing early can't turn into review gating.useAppStoreReviewPrompt, mounted fromBootPopups): when the boot marketing CTA has the new variant, the sheet opens the next time the reader leaves a post (page orpmidmodal) and lands anywhere else. At most once per session, never over another open modal, only in the iOS app with the bridge, and only for logged-in users. Not a boot popup: it waits for the natural pause instead of page load.PlusRenewalReviewModal): a second variant,app_store_review_plus, for the renewal and anniversary campaigns. Plus icon and badge, "Thanks for staying with Plus" (campaigntitle,descriptionandctaTextoverride the copy), a primary Rate daily.dev and "Back to my feed". Only opens for a member; a Plus ask that reaches someone without Plus is cleared so it cannot hold the single boot slot. Two things from the design are left out: the monthly stats, since there is no backend field (the design's "all stats at 0" state), and "Gift Plus to a friend", since gifting does not exist inside the iOS app (StoreKit has no gift product).app_store_review(default on) as a kill switch for both.Impression,Click,dismiss promotionand a newrequest app store reviewevent ontarget_type: app_store_review, with the campaign id (orprofile_menu) as target id.Growth / App Store reviews / Productionrenders the real sheet and row on a fake bridge and lists every message posted to it.For Ante (Customer.io)
Create a
marketing_ctarow and point the campaign at the existing CIO webhook, exactly like the popover campaigns:variantapp_store_review, orapp_store_review_plusfor the renewal thank-youflags.titleflags.descriptionflags.ctaTextflags.imageflags.ctaUrltargets{ "ios": true, "webapp": false, "extension": false, "android": false }One campaign per idea (streak milestone, heavy reading week, Plus renewal, big release) with its own copy and segment. The webapp clears the CTA on close, so the campaign decides frequency; the iOS app also keeps a 120-day cooldown on the pop-up itself.
Apple's limits
requestReviewnever reports whether the pop-up showed, so it can't be caught. Apple caps it at three a year per app and the native side adds a 120-day cooldown. That's why the sheet stands on its own: its CTA goes throughreview-open-store, which always opens Write a Review, and the pop-up is a bonus on top. A reader who taps Rate within the 2s does not get the pop-up as well: they are already writing a review, and it would only spend one of the yearly prompts.Guideline 5.6.1
Review raised App Store guideline 5.6.1, which disallows custom review prompts. The sheets here never draw stars or ask for a rating themselves: the rating pop-up is always Apple's own, requested at a natural pause and never from a tap, and every button is the Write a Review deep link Apple's own sample code documents. That is the reading the design PR (#7329) was built on. It is still our own copy shown unasked, so this needs a product sign-off before the first campaign goes live. The fallback that stays strictly inside the rules is a campaign that only posts
review-requestwith no sheet, which is a one-line change in the hook, plus the user-initiated row.Not in this PR
Testing
ios.spec.ts,AppStoreReviewModal.spec.tsx,PlusRenewalReviewModal.spec.tsx,useAppStoreReviewPrompt.spec.tsx,ResourceSection.spec.tsxandYouPage.spec.tsx; the full shared suite passes.🤖 Generated with Claude Code
Preview domain
https://feat-app-store-reviews.preview.app.daily.dev