Skip to content

feat(ios): App Store review ask and profile row - #7364

Open
rebelchris wants to merge 4 commits into
mainfrom
feat/app-store-reviews
Open

rebelchris wants to merge 4 commits into
mainfrom
feat/app-store-reviews

Conversation

@rebelchris

@rebelchris rebelchris commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

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.

  • iOS bridge (lib/ios.ts): ReviewRequest and ReviewOpenStore handlers, with iOSSupportsReview(), requestIOSReview() and openIOSWriteReview(). Both handlers shipped in iOS 1.26 (dailydotdev/ios#29); older apps show nothing.
  • Ask sheet (AppStoreReviewModal): a new app_store_review marketing CTA variant. Logo (or the campaign's image), 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's title, description or ctaText are empty. About 2s after it opens it posts review-request so Apple's pop-up lands over it; that timer is never cancelled, so closing early can't turn into review gating.
  • Trigger (useAppStoreReviewPrompt, mounted from BootPopups): when the boot marketing CTA has the new variant, the sheet opens the next time the reader leaves a post (page or pmid modal) 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.
  • Plus renewal thank-you (PlusRenewalReviewModal): a second variant, app_store_review_plus, for the renewal and anniversary campaigns. Plus icon and badge, "Thanks for staying with Plus" (campaign title, description and ctaText override 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).
  • Profile row "Show daily.dev some love ❤️", iOS 1.26+ only, opens Write a Review directly; following Apple's guidance a button never requests the pop-up. On an iPhone it lives in the phone shell's You page, in the Help drawer under Changelog. The laptop-width profile dropdown gets the same row for iPads.
  • Flag app_store_review (default on) as a kill switch for both.
  • Logging: Impression, Click, dismiss promotion and a new request app store review event on target_type: app_store_review, with the campaign id (or profile_menu) as target id.
  • Storybook: Growth / App Store reviews / Production renders the real sheet and row on a fake bridge and lists every message posted to it.

For Ante (Customer.io)

Create a marketing_cta row and point the campaign at the existing CIO webhook, exactly like the popover campaigns:

Column Value
variant app_store_review, or app_store_review_plus for the renewal thank-you
flags.title optional, defaults to "A little love goes a long way. ❤️"
flags.description optional, defaults to the storybook body
flags.ctaText optional, defaults to "Rate daily.dev"
flags.image optional square image, replaces the logo (e.g. the Plus icon for the renewal thank-you)
flags.ctaUrl ignored, the bridge has the App Store URL
targets { "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

requestReview never 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 through review-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-request with no sheet, which is a one-line change in the hook, plus the user-initiated row.

Not in this PR

  • The review screen with the wall-of-love testimonial carousel: there is no API for the quotes and the spec rules out a static list in the bundle. The sheet covers the "big release" ask with campaign copy until one exists.
  • Plus renewal stats (clickbait rewritten, posts read, briefings): no backend field; the campaign ships with thank-you copy only.
  • Android and desktop: every surface is gated on the iOS bridge.

Testing

  • ios.spec.ts, AppStoreReviewModal.spec.tsx, PlusRenewalReviewModal.spec.tsx, useAppStoreReviewPrompt.spec.tsx, ResourceSection.spec.tsx and YouPage.spec.tsx; the full shared suite passes.
  • On a device: Apple's pop-up always shows in Xcode development builds and never in TestFlight, so check the sheet, the row and Write a Review in TestFlight and the pop-up timing in a dev build.

🤖 Generated with Claude Code

Preview domain

https://feat-app-store-reviews.preview.app.daily.dev

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>
@vercel

vercel Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
daily-webapp Ready Ready Preview Nov 13, 2026 2:13pm UTC
storybook Error Error Nov 13, 2026 2:13pm UTC

Request Review

@rebelchris rebelchris left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(() => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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]);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 rebelchris left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@rebelchris

Copy link
Copy Markdown
Contributor Author

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 capJavert left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the AskCard (headline, short body, Secondary Small "Rate daily.dev" with AppleIcon) 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_review flag, 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.
  • 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 a campaignId and always calls clearMarketingCta. Make the CTA part optional, or split out the timer and logging, so a surface without a campaign can use it.
  • Cooldown: there's no campaign to control frequency, so store the last-ask time on the device (the spec's review_prompt key in usePersistentContext). 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_review target type with something like target_id: 'streak_milestone' (as profile_menu is used for the row), so it reads apart from campaign asks.
  • Tests: add a case in NewStreakModal / StreakMilestonePopup specs 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.

This branch had an error being deployed

1 failed and 1 active deployments
Preview – daily-webapp — 845f5a8c Deployed Nov 13, 2026 by vercel[bot]
Preview – storybook — 845f5a8c Deployed Nov 13, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants