├── apps
├── native
│ ├── app-env.d.ts
│ ├── .env.example
│ ├── tsconfig.json
│ ├── babel.config.js
│ ├── index.js
│ ├── Button.tsx
│ ├── metro.config.js
│ ├── package.json
│ ├── app.config.ts
│ ├── App.tsx
│ └── .gitignore
└── web
│ ├── app-env.d.ts
│ ├── app
│ ├── favicon.ico
│ ├── checkout_redirect
│ │ ├── cancel
│ │ │ └── page.tsx
│ │ ├── success
│ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── api
│ │ └── stripe
│ │ │ ├── stripe.ts
│ │ │ ├── checkout-session
│ │ │ └── route.ts
│ │ │ └── webhook
│ │ │ └── route.ts
│ ├── layout.tsx
│ ├── styles-provider.tsx
│ ├── .well-known
│ │ └── apple-app-site-association
│ │ │ └── route.ts
│ ├── globals.css
│ └── page.tsx
│ ├── next-env.d.ts
│ ├── .env.example
│ ├── .gitignore
│ ├── package.json
│ ├── tsconfig.json
│ ├── public
│ └── vercel.svg
│ └── next.config.js
├── packages
└── app
│ ├── features
│ ├── auth
│ │ ├── persistence.ts
│ │ ├── persistence.native.ts
│ │ ├── server.ts
│ │ └── client.ts
│ ├── checkout
│ │ ├── cancel
│ │ │ └── screen.tsx
│ │ └── success
│ │ │ └── screen.tsx
│ ├── user
│ │ └── detail-screen.tsx
│ └── home
│ │ └── screen.tsx
│ ├── provider
│ ├── safe-area
│ │ ├── index.native.tsx
│ │ ├── use-safe-area.native.ts
│ │ ├── index.tsx
│ │ └── use-safe-area.ts
│ ├── index.tsx
│ └── navigation
│ │ ├── index.tsx
│ │ └── index.native.tsx
│ ├── index.ts
│ ├── package.json
│ ├── components
│ ├── h1.tsx
│ └── button.tsx
│ ├── env
│ ├── server-env.ts
│ └── public-env.ts
│ ├── rnw-overrides.d.ts
│ └── navigation
│ └── native
│ └── index.tsx
├── .yarnrc.yml
├── tsconfig.json
├── turbo.json
├── package.json
├── .gitignore
├── .vscode
└── settings.json
└── readme.md
/apps/native/app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/apps/web/app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/packages/app/features/auth/persistence.ts:
--------------------------------------------------------------------------------
1 | export default undefined
2 |
--------------------------------------------------------------------------------
/apps/web/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vercel/ios-web-payments/HEAD/apps/web/app/favicon.ico
--------------------------------------------------------------------------------
/apps/native/.env.example:
--------------------------------------------------------------------------------
1 | EXPO_PUBLIC_APP_URL=
2 | EXPO_PUBLIC_BUNDLE_IDENTIFIER=
3 | EXPO_PUBLIC_FIREBASE_CONFIG_JSON=
--------------------------------------------------------------------------------
/packages/app/provider/safe-area/index.native.tsx:
--------------------------------------------------------------------------------
1 | export { SafeAreaProvider as SafeArea } from 'react-native-safe-area-context'
2 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | compressionLevel: mixed
2 |
3 | enableGlobalCache: false
4 |
5 | nodeLinker: node-modules
6 |
7 | yarnPath: .yarn/releases/yarn-4.7.0.cjs
8 |
--------------------------------------------------------------------------------
/packages/app/index.ts:
--------------------------------------------------------------------------------
1 | // leave this blank
2 | // don't re-export files from this workspace. it'll break next.js tree shaking
3 | // https://github.com/vercel/next.js/issues/12557
4 | export {}
5 |
--------------------------------------------------------------------------------
/apps/native/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig",
3 | "compilerOptions": {
4 | "paths": {
5 | "@firebase/auth": ["./node_modules/@firebase/auth/dist/index.rn.d.ts"]
6 | }
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/apps/web/app/checkout_redirect/cancel/page.tsx:
--------------------------------------------------------------------------------
1 | import { CheckoutErrorScreen } from 'app/features/checkout/cancel/screen'
2 |
3 | export default function CheckoutCancelPage() {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/apps/web/app/checkout_redirect/success/page.tsx:
--------------------------------------------------------------------------------
1 | import { CheckoutSuccessScreen } from 'app/features/checkout/success/screen'
2 |
3 | export default function CheckoutSuccessPage() {
4 | return
5 | }
6 |
--------------------------------------------------------------------------------
/apps/native/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function (api) {
2 | api.cache(true)
3 | return {
4 | presets: [['babel-preset-expo', { jsxRuntime: 'automatic' }]],
5 | plugins: ['react-native-reanimated/plugin'],
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/apps/web/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
6 |
--------------------------------------------------------------------------------
/apps/web/app/api/stripe/stripe.ts:
--------------------------------------------------------------------------------
1 | import { SERVER_ENV } from 'app/env/server-env'
2 | import Stripe from 'stripe'
3 |
4 | export const getStripe = () => {
5 | return new Stripe(SERVER_ENV.STRIPE_SECRET_KEY, {
6 | typescript: true,
7 | })
8 | }
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strictNullChecks": true,
4 | "noUncheckedIndexedAccess": true,
5 | "paths": {
6 | "app/*": ["./packages/app/*"]
7 | },
8 | "baseUrl": ".",
9 | "jsx": "react-jsx"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/apps/web/.env.example:
--------------------------------------------------------------------------------
1 | APPLE_TEAM_ID=
2 | FIREBASE_SERVICE_ACCOUNT_JSON=
3 | NEXT_PUBLIC_APP_URL=
4 | NEXT_PUBLIC_BUNDLE_IDENTIFIER=
5 | NEXT_PUBLIC_FIREBASE_CONFIG_JSON=
6 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
7 | STRIPE_PRICE_ID=
8 | STRIPE_SECRET_KEY=
9 | STRIPE_WEBHOOK_SECRET=
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 |
4 | "tasks": {
5 | "build": {
6 | "dependsOn": ["^build"],
7 | "outputs": [".next/**", "!.next/cache/**"],
8 | "inputs": ["$TURBO_DEFAULT$", ".env"]
9 | }
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/packages/app/provider/safe-area/use-safe-area.native.ts:
--------------------------------------------------------------------------------
1 | import { useSafeAreaInsets } from 'react-native-safe-area-context'
2 |
3 | const useSafeArea = useSafeAreaInsets
4 |
5 | // `export { useSafeAreaInsets as useSafeArea }` breaks autoimport, so do this instead
6 | export { useSafeArea }
7 |
--------------------------------------------------------------------------------
/packages/app/features/auth/persistence.native.ts:
--------------------------------------------------------------------------------
1 | // @ts-ignore works on native
2 | import { getReactNativePersistence } from 'firebase/auth'
3 | import ReactNativeAsyncStorage from '@react-native-async-storage/async-storage'
4 |
5 | export default getReactNativePersistence(ReactNativeAsyncStorage)
6 |
--------------------------------------------------------------------------------
/apps/native/index.js:
--------------------------------------------------------------------------------
1 | import { registerRootComponent } from 'expo'
2 |
3 | import App from './App'
4 |
5 | // registerRootComponent calls AppRegistry.registerComponent('main', () => App);
6 | // It also ensures that whether you load the app in Expo Go or in a native build,
7 | // the environment is set up appropriately
8 | registerRootComponent(App)
9 |
--------------------------------------------------------------------------------
/packages/app/features/checkout/cancel/screen.tsx:
--------------------------------------------------------------------------------
1 | import { H1 } from 'app/components/h1'
2 | import { View, Text } from 'react-native'
3 |
4 | export function CheckoutErrorScreen() {
5 | return (
6 |
7 | Checkout canceled!
8 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/packages/app/provider/index.tsx:
--------------------------------------------------------------------------------
1 | import { SafeArea } from 'app/provider/safe-area'
2 | import { NavigationProvider } from './navigation'
3 |
4 | export function Provider({ children }: { children: React.ReactNode }) {
5 | return (
6 |
7 | {children}
8 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/packages/app/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.0.0",
3 | "name": "app",
4 | "main": "index.ts",
5 | "dependencies": {
6 | "@react-navigation/native": "^7.0.15",
7 | "@react-navigation/native-stack": "^7.2.1",
8 | "dripsy": "^4.3.3",
9 | "moti": "^0.30.0",
10 | "solito": "4.4.1"
11 | },
12 | "sideEffects": false
13 | }
14 |
--------------------------------------------------------------------------------
/packages/app/provider/navigation/index.tsx:
--------------------------------------------------------------------------------
1 | // on Web, we don't use React Navigation, so we avoid the provider altogether
2 | // instead, we just have a no-op here
3 | // for more, see: https://solito.dev/recipes/tree-shaking
4 |
5 | export const NavigationProvider = ({
6 | children,
7 | }: {
8 | children: React.ReactElement
9 | }) => <>{children}>
10 |
--------------------------------------------------------------------------------
/packages/app/components/h1.tsx:
--------------------------------------------------------------------------------
1 | import { Text } from 'react-native'
2 |
3 | export const H1 = ({ children }: { children: React.ReactNode }) => {
4 | return (
5 |
13 | {children}
14 |
15 | )
16 | }
17 |
--------------------------------------------------------------------------------
/packages/app/features/user/detail-screen.tsx:
--------------------------------------------------------------------------------
1 | import { View, Text, Pressable } from 'react-native'
2 | import { useRouter } from 'solito/navigation'
3 |
4 | export function UserDetailScreen() {
5 | const router = useRouter()
6 | return (
7 |
8 | router.back()}>
9 | 👈 Go Home
10 |
11 |
12 | )
13 | }
14 |
--------------------------------------------------------------------------------
/packages/app/features/checkout/success/screen.tsx:
--------------------------------------------------------------------------------
1 | import { H1 } from 'app/components/h1'
2 | import { View, Text } from 'react-native'
3 |
4 | export function CheckoutSuccessScreen() {
5 | return (
6 |
14 | ✅
15 | Checkout successful!
16 |
17 | )
18 | }
19 |
--------------------------------------------------------------------------------
/apps/web/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { StylesProvider } from './styles-provider'
2 | import './globals.css'
3 |
4 | export const metadata = {
5 | title: 'Create Solito App',
6 | description: 'Generated by create Solito app',
7 | }
8 |
9 | export default function RootLayout({
10 | children,
11 | }: {
12 | children: React.ReactNode
13 | }) {
14 | return (
15 |
16 |
17 | {children}
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/apps/web/app/styles-provider.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { useServerInsertedHTML } from 'next/navigation'
3 | import { StyleSheet } from 'react-native'
4 |
5 | export function StylesProvider({ children }: { children: React.ReactNode }) {
6 | useServerInsertedHTML(() => {
7 | // @ts-ignore
8 | const sheet = StyleSheet.getSheet()
9 | return (
10 |
14 | )
15 | })
16 | return <>{children}>
17 | }
18 |
--------------------------------------------------------------------------------
/packages/app/env/server-env.ts:
--------------------------------------------------------------------------------
1 | export const SERVER_ENV = {
2 | STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY!,
3 | APPLE_TEAM_ID: process.env.APPLE_TEAM_ID!,
4 | STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID!,
5 | STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET!,
6 | get FIREBASE_SERVICE_ACCOUNT_JSON() {
7 | const str = process.env.FIREBASE_SERVICE_ACCOUNT_JSON
8 | if (!str) {
9 | console.error('process.env.FIREBASE_SERVICE_ACCOUNT_JSON is not set')
10 | return
11 | }
12 | return JSON.parse(str)
13 | },
14 | }
15 |
--------------------------------------------------------------------------------
/packages/app/provider/safe-area/index.tsx:
--------------------------------------------------------------------------------
1 | // on Web, we don't use React Navigation, so we are going to avoid the safe area provider
2 | // instead, we just have a no-op here
3 | // for more, see: https://solito.dev/recipes/tree-shaking
4 |
5 | // if you need safe area hooks yourself, you can implement this yourself
6 | // however, you may be better off using the CSS selector for env(safe-area-inset-top) on Web
7 |
8 | // for more, see the `./use-safe-area.web.ts` file
9 |
10 | export const SafeArea = ({ children }: { children: React.ReactElement }) => (
11 | <>{children}>
12 | )
13 |
--------------------------------------------------------------------------------
/apps/web/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 | .pnpm-debug.log*
27 |
28 | # local env files
29 | .env.local
30 | .env.development.local
31 | .env.test.local
32 | .env.production.local
33 |
34 | # vercel
35 | .vercel
36 |
37 | # typescript
38 | *.tsbuildinfo
39 |
40 |
--------------------------------------------------------------------------------
/apps/web/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-app",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "app": "*",
13 | "firebase-admin": "^13.3.0",
14 | "next": "15.2.6",
15 | "raf": "^3.4.1",
16 | "react": "^19.0.0",
17 | "react-dom": "^19.0.0",
18 | "react-native-web": "~0.19.10",
19 | "stripe": "^18.1.0"
20 | },
21 | "devDependencies": {
22 | "@types/node": "17.0.21",
23 | "eslint-config-next": "13.2.0"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/apps/web/app/checkout_redirect/layout.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | // fallback if universal links fail. can happen in Chrome browsers
4 |
5 | import { PUBLIC_ENV } from 'app/env/public-env'
6 | import { useEffect } from 'react'
7 |
8 | export default function CheckoutRedirectLayout({
9 | children,
10 | }: {
11 | children: React.ReactNode
12 | }) {
13 | useEffect(function redirect() {
14 | try {
15 | const { pathname, search } = new URL(window.location.href)
16 |
17 | let next = `${PUBLIC_ENV.SCHEME}://${pathname}`
18 |
19 | if (search) {
20 | next += `?${search}`
21 | }
22 |
23 | window.location.href = next
24 | } catch {}
25 | }, [])
26 | return <>{children}>
27 | }
28 |
--------------------------------------------------------------------------------
/apps/native/Button.tsx:
--------------------------------------------------------------------------------
1 | import { Pressable, Text } from 'react-native'
2 |
3 | export function Button({
4 | inverse,
5 | title,
6 | onPress,
7 | }: {
8 | inverse: boolean
9 | title: string
10 | onPress: () => void
11 | }) {
12 | return (
13 |
23 |
30 | {title}
31 |
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "solito-blank",
3 | "private": true,
4 | "workspaces": [
5 | "apps/*",
6 | "packages/*"
7 | ],
8 | "devDependencies": {
9 | "@types/react": "^18.2.21",
10 | "eslint": "^8.21.0",
11 | "turbo": "^2.4.4",
12 | "typescript": "^5.2.2"
13 | },
14 | "scripts": {
15 | "native": "cd apps/native && yarn start",
16 | "web": "cd apps/web && yarn next"
17 | },
18 | "packageManager": "yarn@4.7.0",
19 | "eslintConfig": {
20 | "extends": "next",
21 | "settings": {
22 | "next": {
23 | "rootDir": "apps/web/"
24 | }
25 | },
26 | "root": true
27 | },
28 | "prettier": {
29 | "semi": false,
30 | "useTabs": false,
31 | "tabWidth": 2,
32 | "singleQuote": true
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/packages/app/components/button.tsx:
--------------------------------------------------------------------------------
1 | import { Pressable, Text } from 'react-native'
2 |
3 | export function Button({
4 | inverse = false,
5 | title,
6 | onPress,
7 | }: {
8 | inverse?: boolean
9 | title: string
10 | onPress: () => void
11 | }) {
12 | return (
13 |
24 |
31 | {title}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/apps/web/app/.well-known/apple-app-site-association/route.ts:
--------------------------------------------------------------------------------
1 | import { PUBLIC_ENV } from 'app/env/public-env'
2 | import { SERVER_ENV } from 'app/env/server-env'
3 |
4 | const appID = `${SERVER_ENV.APPLE_TEAM_ID}.${PUBLIC_ENV.BUNDLE_IDENTIFIER}`
5 |
6 | export async function GET(request: Request) {
7 | return Response.json(
8 | {
9 | applinks: {
10 | apps: [],
11 | details: [
12 | {
13 | appID: appID,
14 | paths: ['/checkout_redirect*', '/checkout_redirect/*'],
15 | },
16 | ],
17 | },
18 | webcredentials: {
19 | apps: [appID],
20 | },
21 | activitycontinuation: {
22 | apps: [appID],
23 | },
24 | },
25 | {
26 | headers: {
27 | 'Content-Type': 'application/json',
28 | },
29 | status: 200,
30 | }
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/packages/app/rnw-overrides.d.ts:
--------------------------------------------------------------------------------
1 | // override react-native types with react-native-web types
2 | import 'react-native'
3 |
4 | declare module 'react-native' {
5 | interface PressableStateCallbackType {
6 | hovered?: boolean
7 | focused?: boolean
8 | }
9 | interface ViewStyle {
10 | transitionProperty?: string
11 | transitionDuration?: string
12 | }
13 | interface TextProps {
14 | accessibilityComponentType?: never
15 | accessibilityTraits?: never
16 | href?: string
17 | hrefAttrs?: {
18 | rel: 'noreferrer'
19 | target?: '_blank'
20 | }
21 | }
22 | interface ViewProps {
23 | accessibilityRole?: string
24 | href?: string
25 | hrefAttrs?: {
26 | rel: 'noreferrer'
27 | target?: '_blank'
28 | }
29 | onClick?: (e: React.MouseEvent) => void
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/packages/app/env/public-env.ts:
--------------------------------------------------------------------------------
1 | const BUNDLE_IDENTIFIER =
2 | process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER ||
3 | process.env.NEXT_PUBLIC_BUNDLE_IDENTIFIER ||
4 | 'com.solito.payments'
5 |
6 | const APP_URL =
7 | process.env.EXPO_PUBLIC_APP_URL ||
8 | process.env.NEXT_PUBLIC_APP_URL ||
9 | 'https://solito-payments.vercel.app'
10 |
11 | export const PUBLIC_ENV = {
12 | APP_URL,
13 | BUNDLE_IDENTIFIER,
14 | STRIPE_CHECKOUT_URL: `https://${APP_URL}/api/stripe/checkout-session`,
15 | get FIREBASE_CONFIG() {
16 | const str =
17 | process.env.EXPO_PUBLIC_FIREBASE_CONFIG_JSON ||
18 | process.env.NEXT_PUBLIC_FIREBASE_CONFIG_JSON ||
19 | '{}'
20 | // avoid throwing when it's not used on web
21 | return JSON.parse(str!)
22 | },
23 | SCHEME:
24 | process.env.EXPO_PUBLIC_SCHEME ||
25 | process.env.NEXT_PUBLIC_SCHEME ||
26 | 'solito-payments',
27 | }
28 |
--------------------------------------------------------------------------------
/apps/native/metro.config.js:
--------------------------------------------------------------------------------
1 | // Learn more https://docs.expo.dev/guides/monorepos
2 | // Learn more https://docs.expo.io/guides/customizing-metro
3 | /**
4 | * @type {import('expo/metro-config')}
5 | */
6 | const { getDefaultConfig } = require('expo/metro-config')
7 | const path = require('path')
8 |
9 | const projectRoot = __dirname
10 | const workspaceRoot = path.resolve(projectRoot, '../..')
11 |
12 | const config = getDefaultConfig(projectRoot)
13 |
14 | config.watchFolders = [workspaceRoot]
15 | config.resolver.nodeModulesPaths = [
16 | path.resolve(projectRoot, 'node_modules'),
17 | path.resolve(workspaceRoot, 'node_modules'),
18 | ]
19 | config.resolver.disableHierarchicalLookup = true
20 |
21 | config.transformer.getTransformOptions = async () => ({
22 | transform: {
23 | experimentalImportSupport: false,
24 | inlineRequires: true,
25 | },
26 | })
27 |
28 | module.exports = config
29 |
--------------------------------------------------------------------------------
/apps/web/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "baseUrl": ".",
23 | "paths": {
24 | "app/*": ["../../packages/app/*"]
25 | }
26 | },
27 | "include": [
28 | "next-env.d.ts",
29 | "app-env.d.ts",
30 | "**/*.ts",
31 | "**/*.tsx",
32 | "next.config.js",
33 | ".next/types/**/*.ts"
34 | ],
35 | "exclude": ["node_modules"]
36 | }
37 |
--------------------------------------------------------------------------------
/apps/web/app/globals.css:
--------------------------------------------------------------------------------
1 | html,
2 | body,
3 | #__next {
4 | width: 100%;
5 | /* To smooth any scrolling behavior */
6 | -webkit-overflow-scrolling: touch;
7 | margin: 0px;
8 | padding: 0px;
9 | /* Allows content to fill the viewport and go beyond the bottom */
10 | min-height: 100%;
11 | }
12 | #__next {
13 | flex-shrink: 0;
14 | flex-basis: auto;
15 | flex-direction: column;
16 | flex-grow: 1;
17 | display: flex;
18 | flex: 1;
19 | }
20 | html {
21 | scroll-behavior: smooth;
22 | /* Prevent text size change on orientation change https://gist.github.com/tfausak/2222823#file-ios-8-web-app-html-L138 */
23 | -webkit-text-size-adjust: 100%;
24 | height: 100%;
25 | }
26 | body {
27 | display: flex;
28 | /* Allows you to scroll below the viewport; default value is visible */
29 | overflow-y: auto;
30 | overscroll-behavior-y: none;
31 | text-rendering: optimizeLegibility;
32 | -webkit-font-smoothing: antialiased;
33 | -moz-osx-font-smoothing: grayscale;
34 | -ms-overflow-style: scrollbar;
35 | }
36 |
--------------------------------------------------------------------------------
/packages/app/provider/navigation/index.native.tsx:
--------------------------------------------------------------------------------
1 | import { NavigationContainer } from '@react-navigation/native'
2 | import { PUBLIC_ENV } from 'app/env/public-env'
3 | import * as Linking from 'expo-linking'
4 | import { useMemo } from 'react'
5 |
6 | export function NavigationProvider({
7 | children,
8 | }: {
9 | children: React.ReactNode
10 | }) {
11 | return (
12 | ({
15 | prefixes: [
16 | Linking.createURL('/'),
17 | `https://${PUBLIC_ENV.APP_URL}`,
18 | `http://${PUBLIC_ENV.APP_URL}`,
19 | ],
20 | config: {
21 | initialRouteName: 'home',
22 | screens: {
23 | home: '',
24 | 'checkout-success': 'checkout_redirect/success',
25 | 'checkout-cancel': 'checkout_redirect/cancel',
26 | },
27 | },
28 | }),
29 | []
30 | )}
31 | >
32 | {children}
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/packages/app/features/auth/server.ts:
--------------------------------------------------------------------------------
1 | // you can replace this file with your own auth
2 |
3 | import { initializeApp, cert, getApps } from 'firebase-admin/app'
4 | import { DecodedIdToken, getAuth } from 'firebase-admin/auth'
5 | import { SERVER_ENV } from 'app/env/server-env'
6 |
7 | const getApp = () => {
8 | if (!SERVER_ENV.FIREBASE_SERVICE_ACCOUNT_JSON) {
9 | console.error(
10 | 'process.env.FIREBASE_SERVICE_ACCOUNT_JSON is not set. verifyToken will not work.'
11 | )
12 | return
13 | }
14 | const apps = getApps()
15 | if (apps.length === 0) {
16 | return initializeApp({
17 | credential: cert(SERVER_ENV.FIREBASE_SERVICE_ACCOUNT_JSON),
18 | })
19 | }
20 | return apps[0]
21 | }
22 |
23 | export const serverAuth = {
24 | verifyToken: async (token: string): Promise => {
25 | const app = getApp()
26 | if (!app) {
27 | return null
28 | }
29 | const firebaseAuth = getAuth(app)
30 | const decodedToken = await firebaseAuth.verifyIdToken(token)
31 | return decodedToken
32 | },
33 | }
34 |
--------------------------------------------------------------------------------
/apps/web/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/native/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "dependencies": {
3 | "@react-native-async-storage/async-storage": "^2.1.2",
4 | "app": "*",
5 | "expo": "~52.0.46",
6 | "expo-image": "~2.0.7",
7 | "expo-linear-gradient": "~14.0.2",
8 | "expo-linking": "~7.0.5",
9 | "expo-splash-screen": "~0.29.24",
10 | "expo-status-bar": "~2.0.1",
11 | "expo-web-browser": "^14.1.6",
12 | "firebase": "^11.6.1",
13 | "moti": "^0.30.0",
14 | "react-native": "0.76.9",
15 | "react-native-gesture-handler": "~2.20.2",
16 | "react-native-reanimated": "3.16.1",
17 | "react-native-safe-area-context": "4.12.0",
18 | "react-native-screens": "~4.4.0"
19 | },
20 | "devDependencies": {
21 | "@babel/core": "^7.24.0",
22 | "@types/react": "~18.3.12",
23 | "babel-plugin-react-compiler": "19.0.0-beta-3229e95-20250315",
24 | "react-compiler-runtime": "19.0.0-beta-3229e95-20250315",
25 | "typescript": "~5.3.3"
26 | },
27 | "scripts": {
28 | "start": "expo start",
29 | "android": "expo run:android",
30 | "ios": "expo run:ios"
31 | },
32 | "main": "index.js",
33 | "version": "1.0.0",
34 | "private": true,
35 | "name": "expo-app",
36 | "expo": {
37 | "install": {
38 | "exclude": [
39 | "react"
40 | ]
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # OSX
2 | #
3 | .DS_Store
4 |
5 | # Xcode
6 | #
7 | build/
8 | *.pbxuser
9 | !default.pbxuser
10 | *.mode1v3
11 | !default.mode1v3
12 | *.mode2v3
13 | !default.mode2v3
14 | *.perspectivev3
15 | !default.perspectivev3
16 | xcuserdata
17 | *.xccheckout
18 | *.moved-aside
19 | DerivedData
20 | *.hmap
21 | *.ipa
22 | *.xcuserstate
23 | project.xcworkspace
24 |
25 | # Android/IntelliJ
26 | #
27 | build/
28 | .idea
29 | .gradle
30 | local.properties
31 | *.iml
32 |
33 | # node.js
34 | #
35 | node_modules/
36 | npm-debug.log
37 | yarn-error.log
38 |
39 | # BUCK
40 | buck-out/
41 | \.buckd/
42 | *.keystore
43 |
44 | # fastlane
45 | #
46 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
47 | # screenshots whenever they are needed.
48 | # For more information about the recommended setup visit:
49 | # https://docs.fastlane.tools/best-practices/source-control/
50 |
51 | */fastlane/report.xml
52 | */fastlane/Preview.html
53 | */fastlane/screenshots
54 |
55 | # Bundle artifacts
56 | *.jsbundle
57 |
58 | # CocoaPods
59 | /ios/Pods/
60 |
61 | # Expo
62 | .expo/*
63 | web-build/
64 |
65 | **/*/.expo
66 |
67 | **/*/.next
68 |
69 | apps/expo/ios
70 | apps/expo/android
71 |
72 | .turbo
73 | build/**
74 |
75 | .pnp.*
76 | .yarn/*
77 | !.yarn/patches
78 | !.yarn/plugins
79 | !.yarn/releases
80 | !.yarn/sdks
81 | !.yarn/versions
--------------------------------------------------------------------------------
/apps/native/app.config.ts:
--------------------------------------------------------------------------------
1 | import { ExpoConfig } from 'expo/config'
2 |
3 | const BUNDLE_IDENTIFIER =
4 | process.env.EXPO_PUBLIC_BUNDLE_IDENTIFIER || 'com.solito.payments'
5 |
6 | const APP_URL = process.env.EXPO_PUBLIC_APP_URL || 'solito-payments.vercel.app'
7 |
8 | // TODO does expo not support importing these from the shared package?
9 | const PUBLIC_ENV = {
10 | BUNDLE_IDENTIFIER,
11 | APP_URL,
12 | SCHEME: 'solito-payments',
13 | }
14 |
15 | export default {
16 | name: 'Solito Payments',
17 | slug: 'solito-payments',
18 | version: '1.0.0',
19 | scheme: PUBLIC_ENV.SCHEME,
20 | platforms: ['ios', 'android'],
21 | ios: {
22 | bundleIdentifier: PUBLIC_ENV.BUNDLE_IDENTIFIER,
23 | associatedDomains: [`applinks:${PUBLIC_ENV.APP_URL}`],
24 | },
25 | android: {
26 | intentFilters: [
27 | {
28 | action: 'VIEW',
29 | autoVerify: true,
30 | data: [
31 | {
32 | scheme: 'https',
33 | host: `*.${PUBLIC_ENV.APP_URL}`,
34 | pathPrefix: '/',
35 | },
36 | ],
37 | category: ['BROWSABLE', 'DEFAULT'],
38 | },
39 | ],
40 | },
41 | newArchEnabled: true,
42 | experiments: {
43 | reactCanary: true,
44 | reactCompiler: true,
45 | },
46 | plugins: [
47 | [
48 | 'expo-web-browser',
49 | {
50 | experimentalLauncherActivity: true,
51 | },
52 | ],
53 | ],
54 | } satisfies ExpoConfig
55 |
--------------------------------------------------------------------------------
/packages/app/provider/safe-area/use-safe-area.ts:
--------------------------------------------------------------------------------
1 | // I don't use the real useSafeAreaInsets() hook, since
2 | // 1) the SafeAreaProvider forces you to render null on Web until it measures
3 | // 2) you might not need to support it, unless you're doing landscape stuff
4 | // 3) react-native-safe-area-context has a massive import on Web
5 | // see: https://github.com/th3rdwave/react-native-safe-area-context/pull/189#issuecomment-815274313
6 | // 4) most importantly, I think you can just use the env(safe-area-inset-bottom) CSS variable instead
7 | // after all, safe area code is few-and-far-between, so if you have to write some platform-speciifc code for it,
8 | // that is probably better than a massive bundle size for little benefit
9 |
10 | import type { useSafeArea as nativeHook } from './use-safe-area.native'
11 |
12 | const area = {
13 | bottom: 0,
14 | left: 0,
15 | right: 0,
16 | top: 0,
17 |
18 | // you could also use CSS env variables like below:
19 | // but you'll have to be sure to override the types for `useSafeArea`
20 | // and make sure to never add numbers and strings when you consue useSafeArea
21 | // just keep in mind that the env() doesn't work on older browsers I think
22 |
23 | // top: `env(safe-area-inset-top)`,
24 | // right: `env(safe-area-inset-right)`,
25 | // bottom: `env(safe-area-inset-bottom)`,
26 | // left: `env(safe-area-inset-left)`,
27 | }
28 |
29 | export function useSafeArea(): ReturnType {
30 | return area
31 | }
32 |
--------------------------------------------------------------------------------
/packages/app/navigation/native/index.tsx:
--------------------------------------------------------------------------------
1 | import { createNativeStackNavigator } from '@react-navigation/native-stack'
2 | import { Button } from 'react-native'
3 | import { Auth } from 'app/features/auth/client'
4 |
5 | import { HomeScreen } from 'app/features/home/screen'
6 | import { CheckoutSuccessScreen } from 'app/features/checkout/success/screen'
7 | import { CheckoutErrorScreen } from 'app/features/checkout/cancel/screen'
8 |
9 | const Stack = createNativeStackNavigator<{
10 | home: undefined
11 |
12 | 'checkout-success': undefined
13 | 'checkout-cancel': undefined
14 | }>()
15 |
16 | export function NativeNavigation() {
17 | return (
18 |
19 | (
26 |
49 | )
50 | }
51 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "explorer.fileNesting.enabled": true,
3 | "explorer.fileNesting.patterns": {
4 | "*.js": "${capture}.js.map, ${capture}.d.ts, ${capture}.d.ts.map",
5 | "*.ts": "$(capture).test.ts, $(capture).benchmark.ts, $(capture).test.tsx, $(capture).test.node.ts, $(capture).test.node.tsx, $(capture).test.native.ts, $(capture).test.native.tsx, $(capture).test.ios.ts, $(capture).test.ios.tsx, $(capture).test.web.ts, $(capture).test.web.tsx, $(capture).test.android.ts, $(capture).test.android.tsx, ${capture}.native.tsx, ${capture}.ios.tsx, ${capture}.android.tsx, ${capture}.web.tsx, ${capture}.native.ts, ${capture}.ios.ts, ${capture}.android.ts, ${capture}.web.ts, ${capture}.native.js, ${capture}.ios.js, ${capture}.android.js, ${capture}.web.js, ${capture}.native.jsx, ${capture}.ios.jsx, ${capture}.android.jsx, ${capture}.web.jsx",
6 | "*.tsx": "$(capture).test.ts, $(capture).test.tsx, $(capture).test.node.ts, $(capture).test.node.tsx, $(capture).test.native.ts, $(capture).test.native.tsx, $(capture).test.ios.ts, $(capture).test.ios.tsx, $(capture).test.web.ts, $(capture).test.web.tsx, $(capture).test.android.ts, $(capture).test.android.tsx, ${capture}.native.tsx, ${capture}.ios.tsx, ${capture}.types.tsx, ${capture}.android.tsx, ${capture}.web.tsx, ${capture}.native.ts, ${capture}.types.ts, ${capture}.ios.ts, ${capture}.android.ts, ${capture}.web.ts, ${capture}.native.js, ${capture}.ios.js, ${capture}.android.js, ${capture}.web.js, ${capture}.native.jsx, ${capture}.ios.jsx, ${capture}.android.jsx, ${capture}.web.jsx"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/apps/web/app/api/stripe/checkout-session/route.ts:
--------------------------------------------------------------------------------
1 | import { SERVER_ENV } from 'app/env/server-env'
2 | import { PUBLIC_ENV } from 'app/env/public-env'
3 | import { getStripe } from '../stripe'
4 | import { serverAuth } from 'app/features/auth/server'
5 |
6 | export async function POST(req: Request) {
7 | const token = req.headers.get('Authorization')
8 | if (!token) {
9 | return Response.json({ error: 'Missing auth token' }, { status: 401 })
10 | }
11 |
12 | let userId: string
13 |
14 | try {
15 | // 👋 in production, you should associate this request with a customer in your DB
16 | // here, we're just using the user's auth ID
17 | // but you might match it up with a team ID
18 | const { sub } = await serverAuth.verifyToken(token.split(' ')[1])
19 | userId = sub
20 | } catch (error) {
21 | return Response.json({ error: 'Invalid auth token' }, { status: 401 })
22 | }
23 |
24 | // 👋 in production, you should store your customer ID in your DB
25 | // and retrieve it here based off the user's auth ID
26 | const customerId: string | undefined = undefined
27 |
28 | const priceId = SERVER_ENV.STRIPE_PRICE_ID
29 | const stripe = getStripe()
30 |
31 | const session = await stripe.checkout.sessions.create({
32 | payment_method_types: ['card'],
33 | line_items: [
34 | {
35 | price: priceId,
36 | quantity: 1,
37 | },
38 | ],
39 | mode: 'payment',
40 | customer: customerId,
41 | success_url: `https://${PUBLIC_ENV.APP_URL}/checkout_redirect/success`,
42 | cancel_url: `https://${PUBLIC_ENV.APP_URL}/checkout_redirect/cancel`,
43 | })
44 |
45 | return Response.json({ url: session.url })
46 | }
47 |
--------------------------------------------------------------------------------
/apps/native/App.tsx:
--------------------------------------------------------------------------------
1 | import { NativeNavigation } from 'app/navigation/native'
2 | import { Provider } from 'app/provider'
3 | import { Auth } from 'app/features/auth/client'
4 | import { StyleSheet, View } from 'react-native'
5 | import { useState } from 'react'
6 | import { Button } from 'app/components/button'
7 |
8 | export default function App() {
9 | return (
10 |
11 |
12 | {(auth) => {
13 | if (auth.loading) {
14 | return
15 | }
16 | if (!auth.user) {
17 | return
18 | }
19 | return
20 | }}
21 |
22 |
23 | )
24 | }
25 |
26 | function Loading() {
27 | return (
28 |
31 | )
32 | }
33 |
34 | function Authenticate() {
35 | const [isSigningIn, setIsSigningIn] = useState(false)
36 | return (
37 |
46 |
61 | )
62 | }
63 |
--------------------------------------------------------------------------------
/packages/app/features/auth/client.ts:
--------------------------------------------------------------------------------
1 | import { PUBLIC_ENV } from 'app/env/public-env'
2 | import { initializeApp } from 'firebase/app'
3 | import {
4 | initializeAuth,
5 | signInAnonymously,
6 | onIdTokenChanged,
7 | User,
8 | signOut,
9 | } from 'firebase/auth'
10 | import { useEffect } from 'react'
11 | import { useState } from 'react'
12 | import persistence from './persistence'
13 |
14 | let app: ReturnType | undefined
15 | if (!app) {
16 | app = initializeApp(PUBLIC_ENV.FIREBASE_CONFIG)
17 | }
18 | let auth: ReturnType | undefined
19 | if (!auth) {
20 | auth = initializeAuth(app, {
21 | persistence: persistence,
22 | })
23 | }
24 |
25 | const getToken = async () => {
26 | const user = auth.currentUser
27 | if (!user) return null
28 | const token = await user.getIdToken()
29 | return token
30 | }
31 |
32 | export const Auth = {
33 | signInAnonymously: async () => {
34 | // be sure to enable this in the firebase console
35 | const credential = await signInAnonymously(auth)
36 | return credential.user
37 | },
38 | onIdTokenChanged: (callback: (user: null | { uid: string }) => void) => {
39 | onIdTokenChanged(auth, callback)
40 | },
41 | signOut: async () => {
42 | await signOut(auth)
43 | },
44 | getToken,
45 | AuthGate({
46 | children,
47 | }: {
48 | children: (
49 | props:
50 | | { loading: true }
51 | | {
52 | user: User | null
53 | loading: false
54 | }
55 | ) => React.ReactNode
56 | }) {
57 | const [user, setUser] = useState(undefined)
58 | useEffect(() => {
59 | onIdTokenChanged(auth, setUser)
60 | }, [])
61 |
62 | if (user === undefined) {
63 | return children({ loading: true })
64 | }
65 |
66 | return children({ user, loading: false })
67 | },
68 | }
69 |
--------------------------------------------------------------------------------
/apps/web/app/api/stripe/webhook/route.ts:
--------------------------------------------------------------------------------
1 | import { SERVER_ENV } from 'app/env/server-env'
2 | import { getStripe } from '../stripe'
3 |
4 | export async function POST(req: Request) {
5 | let data
6 | let eventType
7 | const stripe = getStripe()
8 | // Check if webhook signing is configured.
9 | const webhookSecret = SERVER_ENV.STRIPE_WEBHOOK_SECRET
10 | let event:
11 | | Awaited>
12 | | undefined
13 |
14 | if (webhookSecret) {
15 | // Retrieve the event by verifying the signature using the raw body and secret.
16 | let signature = req.headers.get('stripe-signature')
17 |
18 | if (!signature) {
19 | return new Response('No signature', { status: 400 })
20 | }
21 |
22 | try {
23 | event = stripe.webhooks.constructEvent(
24 | await req.text(),
25 | signature,
26 | webhookSecret
27 | )
28 | } catch (err) {
29 | console.log(`⚠️ Webhook signature verification failed.`)
30 | return new Response('Webhook signature verification failed.', {
31 | status: 400,
32 | })
33 | }
34 | // Extract the object from the event.
35 | data = event.data
36 | eventType = event.type
37 | } else {
38 | return new Response(
39 | 'Webhook secret not configured. Please see https://docs.stripe.com/mobile/digital-goods?lang=node&shell=true&api=true#webhooks',
40 | { status: 400 }
41 | )
42 | }
43 |
44 | if (!event) {
45 | return new Response('No event', { status: 400 })
46 | }
47 |
48 | switch (eventType) {
49 | case 'checkout.session.completed':
50 | const session = event.data.object
51 | // const user = myUserDB.userForStripeCustomerID(session.customer)
52 | // user.addCoinsTransaction(100, session.id)
53 | break
54 | default:
55 | // Unhandled event type
56 | }
57 |
58 | return new Response('OK', { status: 200 })
59 | }
60 |
--------------------------------------------------------------------------------
/packages/app/features/home/screen.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Text, View, Linking } from 'react-native'
4 | import { PUBLIC_ENV } from 'app/env/public-env'
5 | import { Button } from 'app/components/button'
6 | import { Auth } from 'app/features/auth/client'
7 | import { useState } from 'react'
8 | import { useSafeAreaInsets } from 'react-native-safe-area-context'
9 | import { H1 } from 'app/components/h1'
10 | export function HomeScreen() {
11 | const { top, bottom } = useSafeAreaInsets()
12 | const [isLoading, setIsLoading] = useState(false)
13 | return (
14 |
24 |
33 | Zero-commission payments.
34 |
35 |
36 |
60 |
61 | )
62 | }
63 |
64 | const P = ({ children }: { children: React.ReactNode }) => {
65 | return {children}
66 | }
67 |
--------------------------------------------------------------------------------
/apps/native/.gitignore:
--------------------------------------------------------------------------------
1 | # OSX
2 | #
3 | .DS_Store
4 |
5 | # Xcode
6 | #
7 | build/
8 | *.pbxuser
9 | !default.pbxuser
10 | *.mode1v3
11 | !default.mode1v3
12 | *.mode2v3
13 | !default.mode2v3
14 | *.perspectivev3
15 | !default.perspectivev3
16 | xcuserdata
17 | *.xccheckout
18 | *.moved-aside
19 | DerivedData
20 | *.hmap
21 | *.ipa
22 | *.xcuserstate
23 | project.xcworkspace
24 |
25 | # Android/IntelliJ
26 | #
27 | build/
28 | .idea
29 | .gradle
30 | local.properties
31 | *.iml
32 | *.hprof
33 |
34 | # node.js
35 | #
36 | node_modules/
37 | npm-debug.log
38 | yarn-error.log
39 |
40 | # BUCK
41 | buck-out/
42 | \.buckd/
43 | *.keystore
44 |
45 | # fastlane
46 | #
47 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the
48 | # screenshots whenever they are needed.
49 | # For more information about the recommended setup visit:
50 | # https://docs.fastlane.tools/best-practices/source-control/
51 |
52 | */fastlane/report.xml
53 | */fastlane/Preview.html
54 | */fastlane/screenshots
55 |
56 | # Bundle artifacts
57 | *.jsbundle
58 |
59 | # CocoaPods
60 | /ios/Pods/
61 |
62 | # Expo
63 | .expo/*
64 | web-build/
65 |
66 | # @generated expo-cli sync-e7dcf75f4e856f7b6f3239b3f3a7dd614ee755a8
67 | # The following patterns were generated by expo-cli
68 |
69 | # OSX
70 | #
71 | .DS_Store
72 |
73 | # Xcode
74 | #
75 | build/
76 | *.pbxuser
77 | !default.pbxuser
78 | *.mode1v3
79 | !default.mode1v3
80 | *.mode2v3
81 | !default.mode2v3
82 | *.perspectivev3
83 | !default.perspectivev3
84 | xcuserdata
85 | *.xccheckout
86 | *.moved-aside
87 | DerivedData
88 | *.hmap
89 | *.ipa
90 | *.xcuserstate
91 | project.xcworkspace
92 |
93 | # Android/IntelliJ
94 | #
95 | build/
96 | .idea
97 | .gradle
98 | local.properties
99 | *.iml
100 | *.hprof
101 |
102 | # node.js
103 | #
104 | node_modules/
105 | npm-debug.log
106 | yarn-error.log
107 |
108 | # BUCK
109 | buck-out/
110 | \.buckd/
111 | *.keystore
112 | !debug.keystore
113 |
114 | # Bundle artifacts
115 | *.jsbundle
116 |
117 | # CocoaPods
118 | /ios/Pods/
119 |
120 | # Expo
121 | .expo/
122 | web-build/
123 | dist/
124 |
125 | # @end expo-cli
126 |
127 | .env
128 |
129 | ios/
130 |
--------------------------------------------------------------------------------
/apps/web/next.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('next').NextConfig}
3 | */
4 | const withWebpack = {
5 | webpack(config) {
6 | if (!config.resolve) {
7 | config.resolve = {}
8 | }
9 |
10 | config.resolve.alias = {
11 | ...(config.resolve.alias || {}),
12 | 'react-native': 'react-native-web',
13 | 'react-native$': 'react-native-web',
14 | 'react-native/Libraries/EventEmitter/RCTDeviceEventEmitter$':
15 | 'react-native-web/dist/vendor/react-native/NativeEventEmitter/RCTDeviceEventEmitter',
16 | 'react-native/Libraries/vendor/emitter/EventEmitter$':
17 | 'react-native-web/dist/vendor/react-native/emitter/EventEmitter',
18 | 'react-native/Libraries/EventEmitter/NativeEventEmitter$':
19 | 'react-native-web/dist/vendor/react-native/NativeEventEmitter',
20 | }
21 |
22 | config.resolve.extensions = [
23 | '.web.js',
24 | '.web.jsx',
25 | '.web.ts',
26 | '.web.tsx',
27 | ...(config.resolve?.extensions ?? []),
28 | ]
29 |
30 | return config
31 | },
32 | }
33 |
34 | /**
35 | * @type {import('next').NextConfig}
36 | */
37 | const withTurpopack = {
38 | experimental: {
39 | turbo: {
40 | resolveAlias: {
41 | 'react-native': 'react-native-web',
42 | 'react-native/Libraries/EventEmitter/RCTDeviceEventEmitter$':
43 | 'react-native-web/dist/vendor/react-native/NativeEventEmitter/RCTDeviceEventEmitter',
44 | 'react-native/Libraries/vendor/emitter/EventEmitter$':
45 | 'react-native-web/dist/vendor/react-native/emitter/EventEmitter',
46 | 'react-native/Libraries/EventEmitter/NativeEventEmitter$':
47 | 'react-native-web/dist/vendor/react-native/NativeEventEmitter',
48 | },
49 | resolveExtensions: [
50 | '.web.js',
51 | '.web.jsx',
52 | '.web.ts',
53 | '.web.tsx',
54 |
55 | '.js',
56 | '.mjs',
57 | '.tsx',
58 | '.ts',
59 | '.jsx',
60 | '.json',
61 | '.wasm',
62 | ],
63 | },
64 | },
65 | }
66 |
67 | /**
68 | * @type {import('next').NextConfig}
69 | */
70 | module.exports = {
71 | transpilePackages: [
72 | 'react-native',
73 | 'react-native-web',
74 | 'solito',
75 | 'react-native-reanimated',
76 | 'moti',
77 | 'react-native-gesture-handler',
78 | ],
79 |
80 | compiler: {
81 | define: {
82 | __DEV__: JSON.stringify(process.env.NODE_ENV !== 'production'),
83 | },
84 | },
85 | reactStrictMode: false, // reanimated doesn't support this on web
86 |
87 | typescript: {
88 | ignoreBuildErrors: true,
89 | },
90 |
91 | ...withWebpack,
92 | ...withTurpopack,
93 | }
94 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | # Solito iOS Web Payments Example
2 |
3 | Example Solito app that uses Stripe checkout on Web with an iOS app.
4 |
5 | As of April 30, 2025, you can use web-based checkout on iOS without incurring Apple's commission.
6 |
7 | Even though you're redirecting users to Web to complete their purchase, **Apple pay is still supported**. See the demo video below.
8 |
9 | ## ⚡️ Instantly clone & deploy API routes
10 |
11 | [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fvercel%2Fios-web-payments&env=APPLE_TEAM_ID,FIREBASE_SERVICE_ACCOUNT_JSON,NEXT_PUBLIC_APP_URL,NEXT_PUBLIC_BUNDLE_IDENTIFIER,NEXT_PUBLIC_FIREBASE_CONFIG_JSON,NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,STRIPE_PRICE_ID,STRIPE_PRODUCT_ID,STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET&root-directory=apps/web)
12 |
13 | ## 🔦 About
14 |
15 | This monorepo implemented `npx create-solito-app@latest`.
16 |
17 | ## 📦 Included packages
18 |
19 | - `solito` for cross-platform navigation
20 | - Next.js 15 for API routes
21 | - Expo 53 + React Native for the native app
22 | - `stripe` for payments
23 | - `firebase` for authentication (you can easily swap it out)
24 | - React Navigation 7
25 | - React 19 (see Solito [compatibility docs](https://solito.dev/compatibility))
26 | - React Compiler
27 |
28 | ## 🗂 Folder layout
29 |
30 | - `apps` entry points for each app
31 |
32 | - `native`
33 | - `web`
34 | - `api`
35 | - Checkout routes
36 | - Apple `.well-known` route
37 |
38 | - `packages` shared packages across apps
39 | - `app` you'll be importing most files from `app/`
40 | - `features` (don't use a `screens` folder. organize by feature.)
41 | - `provider` (all the providers that wrap the app, and some no-ops for Web.)
42 | - `navigation` Next.js has a `pages/` folder. React Native doesn't. This folder contains navigation-related code for RN. You may use it for any navigation code, such as custom links.
43 |
44 | You can add other folders inside of `packages/` if you know what you're doing and have a good reason to.
45 |
46 | ## 🏁 Start the app
47 |
48 | - Install dependencies: `yarn`
49 |
50 | - Next.js local dev: `yarn web`
51 | - Runs `yarn next` in `apps/web`
52 | - Expo local dev:
53 | - First, build a dev client onto your device or simulator
54 | - `cd apps/native`
55 | - Then, either `npx expo run:ios`
56 | - After building the dev client, from the root of the monorepo...
57 | - `yarn native` (This runs `npx expo start --dev-client`)
58 |
59 | ## 🎙 About Solito
60 |
61 | See the [Solito docs](https://solito.dev) for more information.
62 |
63 | ## About this example
64 |
65 | This example was created using the [Solito starter](https://github.com/nandorojo/solito/tree/master/example-monorepos/blank). Please refer to that starter's README for more information on development.
66 |
--------------------------------------------------------------------------------
/apps/web/app/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 | import { View, Text, StyleSheet } from 'react-native'
3 |
4 | export default function Page() {
5 | return (
6 |
7 |
8 |
9 | Zero-commission iOS app payments with Solito.
10 |
11 |
12 |
13 |
14 |
15 | This repo is a demonstration of an iOS app with zero-commission
16 | payments on the web. It is based on the Solito starter, with Next.js
17 | API routes and a React Native mobile app.
18 |
19 |
20 |
21 | Run it yourself:
22 |
23 | 1.{' '}
24 |
34 | Clone the repo & deploy the API to Vercel
35 |
36 |
37 |
38 | 2.{' '}
39 |
49 | Reference the README on GitHub to run the iOS app locally
50 |
51 |
52 |
53 |
54 |
55 | Key Benefits:
56 |
57 | • In-app steering to external payment pages is allowed
58 |
59 |
60 | • No more Apple tax on payments they don't process
61 |
62 |
63 | • Improved user experience without "scare screens"
64 |
65 |
66 | • Full control over your payment experience
67 |
68 |
69 | • Stripe payment integration
70 |
71 |
72 | • Apple Pay support on web
73 |
74 |
75 |
76 |
77 | As of April 30, 2025, iOS developers can offer web-based checkout
78 | without incurring Apple's commission, thanks to a federal court
79 | ruling in Epic Games v. Apple.
80 |
81 |
82 |
85 | // Linking.openURL(
86 | // 'https://vercel.com/blog/ios-developers-can-now-offer-commission-free-payments-on-web-7mmIZlW7XQFEdh2Fd7PAXB'
87 | // )
88 | // }
89 | href="https://vercel.com/blog/ios-developers-can-now-offer-commission-free-payments-on-web-7mmIZlW7XQFEdh2Fd7PAXB"
90 | target="_blank"
91 | rel="noopener noreferrer"
92 | hrefAttrs={{
93 | rel: 'noopener noreferrer',
94 | target: '_blank',
95 | }}
96 | >
97 | Read the full article on Vercel's blog →
98 |
99 |
100 |
101 | )
102 | }
103 |
104 | const styles = StyleSheet.create({
105 | container: {
106 | flex: 1,
107 | padding: 20,
108 | maxWidth: 800,
109 | width: '100%',
110 | marginHorizontal: 'auto',
111 | },
112 | header: {
113 | marginBottom: 40,
114 | marginTop: 60,
115 | },
116 | title: {
117 | fontSize: 42,
118 | fontWeight: 'bold',
119 | color: '#000',
120 | marginBottom: 8,
121 | },
122 | subtitle: {
123 | fontSize: 24,
124 | color: '#666',
125 | },
126 | content: {
127 | marginBottom: 40,
128 | },
129 | paragraph: {
130 | fontSize: 18,
131 | lineHeight: 26,
132 | marginBottom: 20,
133 | color: '#333',
134 | },
135 | highlight: {
136 | fontSize: 18,
137 | lineHeight: 26,
138 | padding: 15,
139 | backgroundColor: '#f0f9ff',
140 | borderRadius: 8,
141 | borderLeftWidth: 4,
142 | borderLeftColor: '#0070f3',
143 | marginBottom: 24,
144 | color: '#333',
145 | },
146 | features: {
147 | marginBottom: 20,
148 | marginTop: 20,
149 | },
150 | featureTitle: {
151 | fontSize: 20,
152 | fontWeight: 'bold',
153 | marginBottom: 20,
154 | color: '#333',
155 | },
156 | featureItem: {
157 | fontSize: 18,
158 | lineHeight: 26,
159 | marginBottom: 8,
160 | color: '#333',
161 | },
162 | link: {
163 | fontSize: 18,
164 | color: '#0070f3',
165 | textDecorationLine: 'underline',
166 | // marginTop: 10,
167 | marginBottom: 20,
168 | },
169 | })
170 |
--------------------------------------------------------------------------------