Pattern paywall với RevenueCat: timeline dùng thử, exit offer, gói của bạn
Pattern paywall với RevenueCat trong app React Native: timeline trial trung thực, exit offer một lần, offer code và hiện gói cho người đã mua.

Trong bài này
Cả bảy app của mình đều bán qua RevenueCat (react-native-purchases). Phần lớn bán subscription; Lockboxy chẳng hạn có gói tháng, gói năm kèm dùng thử miễn phí, và gói trọn đời. Linkeeper thì chỉ bán đúng một lần mở khoá (non-consumable). Qua vài bản phát hành gần đây, paywall của Lockboxy, Minivid, Talkzy và Stampzy dần có chung một hình dạng. Bài này gom lại những pattern còn giữ được.
Chúng không đến từ một buổi thiết kế. Changelog của Lockboxy ghi rõ lý do: RevenueCat cho thấy người cài mới gần như không chuyển đổi. Paywall khi đó mặc định chọn gói trọn đời, hứa "dùng thử 3 ngày" với tất cả mọi người dù store có cho hay không, còn sheet báo hết giới hạn free thì đọc như một bức tường. Bài này kể những gì đã thay thế chúng, cộng thêm một bug ở phía bên kia giao dịch mà tuần này mình mới sửa.
Cùng một bộ khung trong mọi app
App nào cũng có interface BillingPort, và revenueCatBillingAdapter.ts là file duy nhất import react-native-purchases. Khi không có key thật thì một fake billing port thế chỗ. Nhờ vậy simulator, test và các lần chụp screenshot cho App Store đều mở được paywall mà không cần StoreKit sandbox.
Việc chặn tính năng chỉ hỏi một câu là isPremium(). Giá trị này luôn mới nhờ listener customer-info của RevenueCat thông qua onEntitlementChange. Mua, khôi phục, đổi offer code hay hết hạn đều lật đúng cái boolean đó, và màn hình nào quan tâm sẽ render lại. Listener này sẽ quay lại ở phần bug cuối bài.
Timeline dùng thử lấy từ store
Paywall chỉ hiện trial khi store xác nhận người này thật sự được hưởng. checkTrialOrIntroductoryPriceEligibility của RevenueCat phải trả về ELIGIBLE; UNKNOWN được coi là không, đúng như RevenueCat khuyên. Ai không đủ điều kiện thì không thấy chữ "dùng thử" ở đâu cả: không trên thẻ gói, không trên nút, không ở footer.
Khi có trial, nó được trình bày thành một timeline chứ không phải một dòng chữ nhỏ:

Các bước được dựng từ khoảng intro thật của store, không bao giờ từ một con số viết cứng trong code:
export function deriveTrialTimeline(intro?: BillingIntroOffer) {
const days = trialLengthDays(intro); // period × count × cycles, null nếu không phải free trial
if (days === null) return null;
const steps: TrialTimelineStep[] = [{ kind: 'today' }];
if (days >= 2) steps.push({ kind: 'reminder', day: days - 1 });
steps.push({ kind: 'billing', day: days });
return steps;
}Cuối tháng 9 cách làm này có lời. Trial gói năm của Lockboxy đổi từ 3 lên 7 ngày trong App Store Connect mà không phải sửa dòng code nào: phụ đề gói, điều khoản ở footer và timeline đều đọc {{count}} từ intro offer, ở cả mười ngôn ngữ. Để giữ được như vậy, giờ có một test locale sẽ fail nếu chuỗi trial hay nhắc nhở nào tự chứa chữ số.
Lời nhắc là thật, vì timeline đã hứa. Sau khi mua trial, app xin quyền thông báo đúng lúc lời hứa vừa được đưa ra. Talkzy và Stampzy còn hiện trước một primer sheet ngắn giải thích rằng chỉ có một thông báo duy nhất. Sau đó app hẹn một local notification vào ngày trước khi hết trial, dựa trên ngày hết hạn RevenueCat trả về (periodType === 'TRIAL'), và nếu không có thì tính từ khoảng intro. Ở Lockboxy, mỗi lần mở khoá vào kho chính sẽ kiểm tra lại, và huỷ lời nhắc khi auto-renew đã tắt. Trial một ngày thì không có bước nhắc, vì lời nhắc sẽ tới sau khi đã bị trừ tiền.
Phần còn lại của paywall theo cùng một nguyên tắc: không đoán. Gói năm được chọn sẵn. Giá quy ra mỗi tháng lấy từ pricePerMonthString của chính store, còn phần trăm tiết kiệm tính từ giá thật và làm tròn xuống. Gói trọn đời nằm cuối làm mốc neo. Footer cố định luôn ghi giá, chu kỳ và việc tự gia hạn của gói đang chọn, kèm Restore, Terms và Privacy. Footer đó chính là thứ guideline 3.1.2 của App Review yêu cầu.
Một exit offer, chọn theo người đang đóng
Lần đầu người chưa có Premium đóng paywall, họ thấy một sheet giảm giá năm đầu với đồng hồ đếm ngược 24 giờ. Sau đó nó không bao giờ hiện lại, dù họ bỏ qua, nhận hay để hết giờ. Đồng hồ được lưu trên máy, nên nếu app bị kill khi sheet đang mở, lần đóng sau sẽ tiếp tục đúng hạn cũ chứ không đếm lại 24 giờ mới.

Có hai biến thể là vì một điều mình phát hiện trong sandbox khi làm bản của Minivid: Apple không ký promotional offer cho khách chưa từng đăng ký. RevenueCat trả lời từ chối bằng câu "The User is ineligible for that action." Vì vậy:
- Người từng đăng ký nhận promotional offer, lấy bằng
getPromotionalOffervà mua ngay trong app bằngpurchaseDiscountedPackage, hiện đúng giá giảm đã bản địa hoá của store. Trên thực tế đây là offer kéo khách cũ quay lại. - Khách chưa từng đăng ký nhận một App Store offer code. Nút "Đổi ưu đãi" copy mã rồi mở sheet của chính Apple bằng
presentCodeRedemptionSheet(). StoreKit không báo giá được cho offer code, nên lời mời ghi phần trăm và nói rõ Apple sẽ hiện giá chính xác trước khi bạn xác nhận. Nó không bao giờ hiện một con số mà app không kiểm chứng được.
Adapter kiểm tra allPurchasedProductIdentifiers xem đã từng mua gói tháng hay gói năm chưa, trước khi xin chữ ký. Nhờ vậy khách mới không bao giờ kích hoạt một request chắc chắn sẽ thất bại. Nếu sheet không mô tả trung thực được offer là "năm đầu tiên của bạn", nó không được hiện.
Đi kèm là vài rào chắn:
- Minivid không hiện exit offer trên paywall của onboarding. Ở phút đầu tiên, khi người dùng còn chưa dùng app lần nào, một lời giảm giá đọc như gây áp lực hơn là một cơ hội thứ hai.
- Stampzy để biến thể offer code sau một flag, tắt cho tới khi App Store Connect có mã.
- Lockboxy chỉ giữ offer lại khi người dùng tự bấm quay lại. Một lần reset do panic lock hay auto-lock không bao giờ bị sheet làm chậm.
Mã đã đổi không quay về qua sheet. Nó tới sau, qua listener customer-info: entitlement được lật, sheet tự đóng, và thao tác đóng paywall bị ngắt giữa chừng được làm nốt.
Bug: Cài đặt vẫn ghi "Go Premium"

Bug này do chính mình báo. Mua Lockboxy Premium xong, Cài đặt vẫn hiện thẻ mời Go Premium, bấm vào thì ra đúng trang bán hàng cũ. Phần billing không hỏng gì: entitlement đang active, tính năng đã mở. Nhưng PremiumScreen có theo dõi isPremiumNow, mà chỉ đưa nó cho analytics và exit offer, không hề dùng cho phần render. Với người vừa trả tiền, app trông như vẫn đang chìa tay xin tiền.
Bản sửa vào build 1.2.12 (45) ngày 1/10, gồm ba phần:
- Một hàm đọc membership tách khỏi việc chặn tính năng.
BillingPort.getMembership()trảnullnếu chưa Premium và không bao giờ reject. Nó đọc product, ngày hết hạn, cờ auto-renew và cờ trial từ entitlement đang active, không phải từ một giao dịch đã cache:
const e = info.entitlements.active['premium'];
if (!e) return null;
return {
productId: toBillingProductIdFromVendorId(e.productIdentifier) ?? null,
expiresAtMs: e.expirationDate ? Date.parse(e.expirationDate) : null,
willRenew: e.willRenew,
isTrial: e.periodType === 'TRIAL',
};- Câu chữ là một hàm thuần.
membershipStatus()chọn đúng một câu: trọn đời, "Gia hạn ngày …", "Kết thúc ngày … · đã tắt tự gia hạn", hoặc bản trial của hai câu đó. Product lạ hoặc thiếu ngày thì chỉ ghi "Đang hoạt động". App không bao giờ đoán ra một lời hứa kiểu "mãi mãi" hay một ngày gia hạn mà nó không nhìn thấy. - Hai màn hình đổi theo trạng thái sống. Màn Premium render
PremiumActiveViewcho khách đã mua: tên gói, dòng trạng thái, mọi thứ đã mở, và "Quản lý gói", mở trang của store và bị ẩn với gói trọn đời vì chẳng có gì để quản lý. Thẻ trong Cài đặt hiện tên gói và trạng thái thay cho lời mời. Cả hai đều theo listener entitlement, nên mua hay khôi phục xong là đổi ngay, không cần remount.
Linkeeper đã có một phiên bản của việc này từ trước: mua xong, mục trong Cài đặt chuyển từ dòng mở khoá sang dòng báo Linkeeper Unlimited đã là của bạn. Bài học cho các app subscription cũng vậy. Paywall cần một trạng thái "sau khi mua", và bộ test phải render được nó.
Rút ra
- Đọc điều kiện dùng thử từ store, coi "unknown" là không, và dựng timeline từ khoảng intro thật.
- Paywall đã hứa nhắc thì phải hẹn lời nhắc thật, và huỷ nó khi người dùng tắt auto-renew.
- Promotional offer dành cho người từng đăng ký. Khách mới cần offer code. Kiểm tra lịch sử mua trước khi xin chữ ký.
- Exit offer chỉ hiện một lần, không hiện ở onboarding, và không bao giờ báo một giá mà app không kiểm chứng được.
- Thiết kế cả màn hình mà người đã trả tiền nhìn thấy. Chặn tính năng bằng
isPremiumlà chưa đủ nếu giao diện vẫn trông như trang bán hàng.
Quảng cáo khớp vào bản free của chính những app này ra sao thì xem bài AdMob trong app React Native. Cả bảy app đều có trên apps.vanthuongdao.id.vn.
Bài liên quan


