Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/mobile/app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ export default function HomeScreen() {

const handleAddConnection = React.useCallback(() => {
if (atLimit) {
void requestPaywall("connection_limit")
void requestPaywall("saved_connections_cap")
return
}
router.push("/new-connection")
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/app/connect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export default function Connect() {
}
const tier = useAuth.getState().user?.tier ?? "free"
if (isAtTunnelLimit(tier, connections.length)) {
void requestPaywall("connection_limit")
void requestPaywall("saved_connections_cap")
return
}
const id = addConnection({
Expand Down
42 changes: 42 additions & 0 deletions apps/mobile/app/project/[projectId]/browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import XIcon from "lucide-react-native/dist/esm/icons/x"
import { Button } from "@/components/ui/button"
import { Text } from "@/components/ui/text"
import { THEME } from "@/lib/theme"
import { hasEntitlement } from "@crosscode/shared"
import { useAuth } from "@/store/auth.store"
import { requestPaywall } from "@/lib/paywall"
import { useProjectConnection } from "@/lib/project-connection"
import { useProjects } from "@/store/projects.store"
import {
Expand Down Expand Up @@ -77,6 +80,14 @@ export default function BrowserPage() {

const { connection } = useProjectConnection(projectId)
const connected = !!(connection?.url && connection?.token)
const tier = useAuth((s) => s.user?.tier ?? "free")
const allowed = hasEntitlement(tier, "file_browser")

useEffect(() => {
if (connected && !allowed) {
void requestPaywall("file_browser")
}
}, [connected, allowed])
const project = useProjects((s) => s.projects.find((p) => p.id === projectId))
const rootLabel = useMemo(() => {
if (project?.name) return project.name
Expand Down Expand Up @@ -285,6 +296,37 @@ export default function BrowserPage() {
)
}

if (!allowed) {
return (
<View className="flex-1 bg-background">
<View
className="flex-row items-center gap-2 border-b border-accent px-4"
style={{ paddingTop: insets.top + 10, paddingBottom: 10 }}
>
<Button variant="ghost" className="w-10 h-10 rounded-full" onPress={() => router.back()}>
<ArrowLeftIcon size={20} color={t.foreground} />
</Button>
<Text className="text-base font-semibold flex-1">Files</Text>
</View>
<View className="flex-1 items-center justify-center px-8">
<Text className="text-lg font-semibold tracking-tight text-center mb-2">
File browser is Pro
</Text>
<Text className="text-sm text-muted-foreground text-center leading-5">
Browse the whole repo from your phone with Pro.
</Text>
<Button
size="sm"
className="mt-4 rounded-full"
onPress={() => requestPaywall("file_browser", { force: true })}
>
<Text className="text-sm">Upgrade to Pro</Text>
</Button>
</View>
</View>
)
}

return (
<View className="flex-1 bg-background">
<View
Expand Down
28 changes: 27 additions & 1 deletion apps/mobile/app/project/[projectId]/viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { Text } from "@/components/ui/text"
import { THEME } from "@/lib/theme"
import { useProjectConnection } from "@/lib/project-connection"
import { isBinaryPath, languageFromPath, readFileContent } from "@/lib/file-browser"
import { hasEntitlement } from "@crosscode/shared"
import { useAuth } from "@/store/auth.store"
import { requestPaywall } from "@/lib/paywall"
import { CodeBlock } from "@/components/code-block"

const MAX_LINES = 5000
Expand All @@ -28,6 +31,15 @@ export default function ViewerPage() {

const { connection } = useProjectConnection(projectId)

const tier = useAuth((s) => s.user?.tier ?? "free")
const allowed = hasEntitlement(tier, "file_browser")

useEffect(() => {
if (connection?.url && connection?.token && !allowed) {
void requestPaywall("file_browser")
}
}, [connection?.url, connection?.token, allowed])

const [content, setContent] = useState<string | null>(null)
const [truncated, setTruncated] = useState(false)
const [loading, setLoading] = useState(true)
Expand Down Expand Up @@ -107,7 +119,21 @@ export default function ViewerPage() {
)}
</View>

{!connection || !projectId ? null : loading ? (
{!connection || !projectId ? null : !allowed ? (
<View className="flex-1 items-center justify-center px-8">
<Text className="text-lg font-semibold tracking-tight text-center mb-2">File viewer is Pro</Text>
<Text className="text-sm text-muted-foreground text-center leading-5">
View any file from your phone with Pro.
</Text>
<Button
size="sm"
className="mt-4 rounded-full"
onPress={() => requestPaywall("file_browser", { force: true })}
>
<Text className="text-sm">Upgrade to Pro</Text>
</Button>
</View>
) : loading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color={t.mutedForeground} />
<Text className="text-xs text-muted-foreground mt-3">Loading file...</Text>
Expand Down
52 changes: 34 additions & 18 deletions apps/mobile/components/PaywallSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useColorScheme } from "nativewind"
import Zap from "lucide-react-native/dist/esm/icons/zap"
import X from "lucide-react-native/dist/esm/icons/x"
import Check from "lucide-react-native/dist/esm/icons/check"
import { paidPlans, tierTunnelLimits, type BillingCycle, type PaidTier } from "@crosscode/shared"
import { paidPlans, tierTunnelLimits, type BillingCurrency, type BillingCycle, type PaidTier } from "@crosscode/shared"
import { Text } from "@/components/ui/text"
import { Button } from "@/components/ui/button"
import { THEME } from "@/lib/theme"
Expand All @@ -15,7 +15,26 @@ import { usePaywall } from "@/store/paywall.store"
import { createBillingCheckout } from "@/lib/billing"
import { getPaywallCopy, recordPaywallDismiss, refreshTier } from "@/lib/paywall"

const TIERS: PaidTier[] = ["starter", "builder"]
const TIERS: PaidTier[] = ["pro"]

function detectCurrency(): BillingCurrency {
try {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ""
if (tz.includes("Kolkata") || tz.includes("Calcutta")) return "inr"
} catch {}
return "usd"
}

function discountPercent(tier: PaidTier, currency: BillingCurrency): number {
const plan = paidPlans[tier]
return Math.round((1 - plan.yearly[currency] / (plan.monthly[currency] * 12)) * 100)
}

function formatPrice(tier: PaidTier, cycle: BillingCycle, currency: BillingCurrency): string {
const plan = paidPlans[tier]
const amount = cycle === "monthly" ? plan.monthly[currency] : plan.yearly[currency]
return `${currency === "inr" ? "₹" : "$"}${amount.toLocaleString(currency === "inr" ? "en-IN" : "en-US")}`
}

export function PaywallSheet() {
const { colorScheme } = useColorScheme()
Expand All @@ -27,6 +46,7 @@ export function PaywallSheet() {
const serverUrl = useAuth((s) => s.serverUrl)
const sessionToken = useAuth((s) => s.sessionToken)
const [cycle, setCycle] = React.useState<BillingCycle>("monthly")
const [currency] = React.useState<BillingCurrency>(detectCurrency)
const [pendingTier, setPendingTier] = React.useState<PaidTier | null>(null)
const [error, setError] = React.useState<string | null>(null)

Expand Down Expand Up @@ -118,7 +138,7 @@ export function PaywallSheet() {
className="text-sm font-medium capitalize"
style={{ color: selected ? THEME[theme].foreground : THEME[theme].mutedForeground }}
>
{c === "yearly" ? "Yearly · save ~17%" : "Monthly"}
{c === "yearly" ? `Yearly · save ${discountPercent("pro", currency)}%` : "Monthly"}
</Text>
</Pressable>
)
Expand All @@ -128,54 +148,50 @@ export function PaywallSheet() {
<View className="mt-3 gap-2">
{TIERS.map((tier) => {
const plan = paidPlans[tier]
const price = cycle === "monthly" ? plan.monthly.usd : plan.yearly.usd
const price = formatPrice(tier, cycle, currency)
const per = cycle === "monthly" ? "/mo" : "/yr"
const isCurrent = currentTier === tier
const isPopular = tier === "builder"
const isCurrent = currentTier === tier || currentTier === "founder"
return (
<View
key={tier}
className={cn(
"rounded-2xl border p-4",
isPopular ? "border-primary/60 bg-primary/5" : "border-border bg-muted/20"
"rounded-2xl border p-4 border-primary/60 bg-primary/5"
)}
>
<View className="flex-row items-center justify-between">
<View className="flex-row items-center gap-2">
<Text className="text-base font-semibold">{plan.name}</Text>
{isPopular ? (
<View className="rounded-full bg-primary px-2 py-0.5">
<Text className="text-[11px] font-semibold text-primary-foreground">Most tunnels</Text>
</View>
) : null}
<View className="rounded-full bg-primary px-2 py-0.5">
<Text className="text-[11px] font-semibold text-primary-foreground">Reliability + power</Text>
</View>
{isCurrent ? (
<View className="rounded-full bg-muted px-2 py-0.5">
<Text className="text-[11px] font-medium text-muted-foreground">Current</Text>
</View>
) : null}
</View>
<Text className="text-base font-bold">
${price}
{price}
<Text className="text-xs font-normal text-muted-foreground">{per}</Text>
</Text>
</View>
<View className="mt-2 flex-row items-center gap-1.5">
<Check size={14} color={THEME[theme].primary} />
<Text className="text-sm text-muted-foreground">
{tierTunnelLimits[tier]} active tunnel{tierTunnelLimits[tier] === 1 ? "" : "s"} · everything in Free
{tierTunnelLimits[tier]} active tunnels · hosted relay + power features
</Text>
</View>
<Button
className="mt-3 h-11 rounded-full"
variant={isPopular ? "default" : "outline"}
variant="default"
disabled={isCurrent || pendingTier !== null}
onPress={() => openCheckout(tier)}
>
{pendingTier === tier ? (
<ActivityIndicator size="small" />
) : (
<Text className={cn("text-sm font-semibold", !isPopular && "text-foreground")}>
{isCurrent ? `You're on ${plan.name}` : `Upgrade to ${plan.name}`}
<Text className="text-sm font-semibold">
{isCurrent ? `You're on ${currentTier === "founder" ? "Pro Lifetime" : plan.name}` : `Upgrade to ${plan.name}`}
</Text>
)}
</Button>
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/components/TunnelUsageCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,8 +169,8 @@ export function TunnelUsageCard() {
>
<Text className="text-sm font-semibold text-primary">
{stats.totalClients >= 1
? "At your Free limit — upgrade for more tunnels"
: "Need more tunnels? Upgrade"}
? "At your Free limit — Pro gives 5 tunnels + hosted relay"
: "Need more tunnels? Upgrade to Pro"}
</Text>
</Button>
)}
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/components/message-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { PermissionRequest } from "@/store/permissions.store"
import { revertMessage, forkSession } from "@/lib/sessions"
import { useProjectConnection } from "@/lib/project-connection"
import { useSessions } from "@/store/sessions.store"
import { useConnections } from "@/store/connection.store"
import { useModels } from "@/store/models.store"
import { useSelectiveCopy } from "@/store/selective-copy.store"

Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/lib/billing.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export async function createBillingCheckout(
serverUrl: string,
sessionToken: string,
tier: "starter" | "builder",
tier: "pro" | "team",
cycle: "monthly" | "yearly" = "monthly",
): Promise<string | null> {
const response = await fetch(`${serverUrl}/api/billing/checkout`, {
Expand Down
10 changes: 8 additions & 2 deletions apps/mobile/lib/entitlement.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
const PAID_TIERS = ["starter", "builder"]
import { hasEntitlement, type Entitlement } from "@crosscode/shared"
import { useAuth } from "@/store/auth.store"

export const PAID_TIERS = ["pro", "team", "founder", "enterprise"]

export function isPaidTier(tier?: string | null) {
return !!tier && PAID_TIERS.includes(tier)
}


export function can(e: Entitlement, tier?: string | null): boolean {
const t = tier ?? useAuth.getState().user?.tier ?? "free"
return hasEntitlement(t, e)
}
Loading
Loading