feat: referral (#3761)

Co-authored-by: DIYgod <i@diygod.me>
Co-authored-by: Innei <tukon479@gmail.com>
This commit is contained in:
Stephen Zhou 2025-07-10 09:47:11 +08:00 committed by GitHub
parent 79c9b05246
commit 30bb37c7b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 27487 additions and 20935 deletions

View File

@ -287,7 +287,7 @@ export const useEntryActions = ({
view,
),
active: isShowAISummaryOnce,
disabled: userRole === UserRole.Trial,
disabled: userRole === UserRole.Free || userRole === UserRole.Trial,
entryId,
}),
new EntryActionMenuItem({
@ -299,7 +299,7 @@ export const useEntryActions = ({
view,
),
active: isShowAITranslationOnce,
disabled: userRole === UserRole.Trial,
disabled: userRole === UserRole.Free || userRole === UserRole.Trial,
entryId,
}),
new EntryActionMenuItem({

View File

@ -28,7 +28,7 @@ const useCanFollowMoreInboxAndNotify = () => {
const serverConfigs = useServerConfigs()
return useEventCallback((type: "list" | "feed") => {
if (role === UserRole.Trial) {
if (role === UserRole.Free || role === UserRole.Trial) {
const LIMIT =
(type !== "list"
? serverConfigs?.MAX_TRIAL_USER_FEED_SUBSCRIPTION

View File

@ -32,6 +32,7 @@ export const {
signIn,
signOut,
signUp,
subscription,
twoFactor,
unlinkAccount,
updateUser,

View File

@ -12,12 +12,14 @@ import { m } from "motion/react"
import { useState } from "react"
import { Trans, useTranslation } from "react-i18next"
import { useServerConfigs } from "~/atoms/server-configs"
import { useCurrentModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
import { loginHandler } from "~/lib/auth"
import { useAuthProviders } from "~/queries/users"
import { LoginWithPassword, RegisterForm } from "./Form"
import { LegalModalContent } from "./LegalModal"
import { ReferralForm } from "./ReferralForm"
import { TokenModalContent } from "./TokenModal"
interface LoginModalContentProps {
@ -26,6 +28,8 @@ interface LoginModalContentProps {
}
export const LoginModalContent = (props: LoginModalContentProps) => {
const serverConfigs = useServerConfigs()
const modal = useCurrentModal()
const { present } = useModalStack()
@ -149,6 +153,7 @@ export const LoginModalContent = (props: LoginModalContentProps) => {
</div>
</div>
)}
{isRegister && serverConfigs?.REFERRAL_ENABLED && <ReferralForm className="mb-4" />}
{!isEmail && (
<>

View File

@ -0,0 +1,102 @@
// sync this file with apps/ssr/client/modules/referral/index.tsx
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@follow/components/ui/form/index.jsx"
import { Input } from "@follow/components/ui/input/Input.jsx"
import { getStorageNS } from "@follow/utils/ns"
import { cn } from "@follow/utils/utils"
import { zodResolver } from "@hookform/resolvers/zod"
import { useQuery } from "@tanstack/react-query"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { apiClient } from "~/lib/api-fetch"
const formSchema = z.object({
referral: z.string().optional(),
})
function getDefaultReferralCode() {
const urlParams = new URLSearchParams(window.location.search)
const referralCodeFromUrl = urlParams.get("referral")
if (referralCodeFromUrl) {
localStorage.setItem(getStorageNS("referral-code"), referralCodeFromUrl)
}
const referralCodeFromLocalStorage = localStorage.getItem(getStorageNS("referral-code"))
return referralCodeFromUrl || referralCodeFromLocalStorage || ""
}
async function getReferralCycleDays(code: string) {
return apiClient.referrals.days.$get({ query: { code } })
}
export function ReferralForm({ className }: { className?: string }) {
const { t } = useTranslation()
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
referral: getDefaultReferralCode(),
},
})
const { watch } = form
useEffect(() => {
const sub = watch((value) => {
const referralCode = value.referral
if (referralCode) {
localStorage.setItem(getStorageNS("referral-code"), referralCode)
}
})
return () => sub.unsubscribe()
}, [watch])
const referral = watch("referral")
const { data } = useQuery({
queryKey: ["referral", "days", referral],
queryFn: () => getReferralCycleDays(referral || ""),
enabled: !!referral,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const days = data?.data.referralCycleDays || 0
return (
<Form {...form}>
<form className={cn(className)}>
<FormField
control={form.control}
name="referral"
render={({ field }) => (
<FormItem>
<FormLabel>{t("register.referral.label")}</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormDescription>
{days || !referral
? days
? t("register.referral.days", {
days,
})
: t("register.referral.description")
: t("register.referral.invalid")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)
}

View File

@ -418,7 +418,7 @@ export const useRegisterEntryCommands = () => {
icon: <i className="i-mgc-ai-cute-re" />,
category,
run: () => {
if (role === UserRole.Trial) {
if (role === UserRole.Free || role === UserRole.Trial) {
presentActivationModal()
return
}
@ -431,7 +431,7 @@ export const useRegisterEntryCommands = () => {
icon: <i className="i-mgc-translate-2-ai-cute-re" />,
category,
run: () => {
if (role === UserRole.Trial) {
if (role === UserRole.Free || role === UserRole.Trial) {
presentActivationModal()
return
}

View File

@ -17,7 +17,7 @@ const useCanCreateMoreInboxAndNotify = () => {
const presentActivationModal = useActivationModal()
return useEventCallback(() => {
if (role === UserRole.Trial) {
if (role === UserRole.Free || role === UserRole.Trial) {
const can = false
if (!can) {
presentActivationModal()

View File

@ -0,0 +1,508 @@
import { Button } from "@follow/components/ui/button/index.js"
import { Divider } from "@follow/components/ui/divider/Divider.js"
import { UserRole, UserRoleName } from "@follow/constants"
import { IN_ELECTRON } from "@follow/shared"
import { env } from "@follow/shared/env.desktop"
import { useRoleEndAt, useUserRole } from "@follow/store/user/hooks"
import { cn } from "@follow/utils/utils"
import dayjs from "dayjs"
import { Trans } from "react-i18next"
import { useServerConfigs } from "~/atoms/server-configs"
import { subscription } from "~/lib/auth"
import { useReferralInfo } from "~/queries/referral"
import { useSetSettingTab } from "../modal/context"
// Plan configuration types
interface Plan {
id: string
title: string
price: string
period: string
features: string[]
isPopular?: boolean
role: UserRole
isComingSoon?: boolean
tier: number // Add tier for hierarchy comparison
}
// Plan hierarchy: Free (1) < Pro Preview (2) < Pro (3)
const PLAN_TIER_MAP: Record<UserRole, number> = {
[UserRole.Admin]: 4, // Admin has highest tier
[UserRole.Free]: 1,
[UserRole.Trial]: 1, // Same as Free (deprecated)
[UserRole.PreProTrial]: 2, // Same tier as PrePro
[UserRole.PrePro]: 2,
[UserRole.Pro]: 3,
}
// Plan configurations
const PLAN_CONFIGS: Plan[] = [
{
id: "free",
title: UserRoleName[UserRole.Free],
price: "$0",
period: "",
features: ["50 feeds", "10 lists"],
isPopular: false,
role: UserRole.Free,
tier: PLAN_TIER_MAP[UserRole.Free],
},
{
id: "pro-preview",
title: UserRoleName[UserRole.PrePro],
price: "$1 or 3 invitations",
period: "",
features: ["1000 feeds and lists", "10 inboxes", "10 actions", "100 webhooks"],
isPopular: false,
role: UserRole.PrePro,
tier: PLAN_TIER_MAP[UserRole.PrePro],
},
{
id: "pro",
title: UserRoleName[UserRole.Pro],
price: "Coming soon",
period: "",
features: [`Everything in ${UserRoleName[UserRole.PrePro]}`, "Advanced AI features"],
isPopular: false,
role: UserRole.Pro,
isComingSoon: true,
tier: PLAN_TIER_MAP[UserRole.Pro],
},
]
export function SettingPlan() {
const serverConfigs = useServerConfigs()
const requiredInvitationsAmount = serverConfigs?.REFERRAL_REQUIRED_INVITATIONS || 3
const skipPrice = serverConfigs?.REFERRAL_PRO_PREVIEW_STRIPE_PRICE_IN_DOLLAR || 1
const ruleLink = serverConfigs?.REFERRAL_RULE_LINK
const { data: referralInfo } = useReferralInfo()
const validInvitationsAmount = referralInfo?.invitations.filter((i) => i.usedAt).length || 0
const role = useUserRole()
const roleEndDate = useRoleEndAt()
const daysLeft = roleEndDate
? Math.ceil((roleEndDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
: null
return (
<div className="space-y-8">
{/* Description Section */}
<div className="space-y-4">
<p className="text-text-secondary text-sm leading-relaxed">
<Trans
ns="settings"
i18nKey="plan.description"
values={{
day: referralInfo?.referralCycleDays || 45,
}}
components={{
Link: (
<a
href={ruleLink}
className="text-accent hover:text-accent/80 underline underline-offset-2 transition-colors"
target="_blank"
/>
),
}}
/>
</p>
</div>
{/* Plans Grid */}
<div className="@container">
<div className="@md:grid-cols-2 @xl:grid-cols-3 grid grid-cols-1 gap-4">
{PLAN_CONFIGS.map((plan) => (
<PlanCard
key={plan.id}
plan={plan}
currentUserRole={role || null}
daysLeft={daysLeft}
isCurrentPlan={
role === plan.role ||
(plan.role === UserRole.PrePro && role === UserRole.PreProTrial)
}
/>
))}
</div>
</div>
<Divider />
{/* Current Status Card */}
<StatusCard
role={role || UserRole.Free}
roleEndDate={roleEndDate || null}
daysLeft={daysLeft}
validInvitationsAmount={validInvitationsAmount}
requiredInvitationsAmount={requiredInvitationsAmount}
skipPrice={skipPrice}
onUpgrade={async () => {
const res = await subscription.upgrade({
plan: "folo pro preview",
successUrl: env.VITE_WEB_URL,
cancelUrl: env.VITE_WEB_URL,
disableRedirect: IN_ELECTRON,
})
if (IN_ELECTRON && res.data?.url) {
window.open(res.data.url, "_blank")
}
}}
/>
</div>
)
}
// Reusable StatusCard Component
interface StatusCardProps {
role: UserRole
roleEndDate: Date | null
daysLeft: number | null
validInvitationsAmount: number
requiredInvitationsAmount: number
skipPrice: number
onUpgrade: () => void
}
const StatusCard = ({
role,
roleEndDate,
daysLeft,
validInvitationsAmount,
requiredInvitationsAmount,
skipPrice,
onUpgrade,
}: StatusCardProps) => {
const getStatusInfo = () => {
if (role === UserRole.PrePro) {
return {
title: "You have an active Pro Preview plan",
icon: "i-mgc-check-cute-fi",
iconBg: "bg-green",
}
}
if (role === UserRole.PreProTrial) {
return {
title: `Pro Preview trial expires ${dayjs(roleEndDate).format("MMMM D, YYYY")} (${daysLeft} days left)`,
icon: "i-mgc-time-cute-re",
iconBg: "bg-accent",
}
}
return {
title: "Start your journey with our referral program",
icon: "i-mgc-time-cute-re",
iconBg: "bg-accent",
}
}
const statusInfo = getStatusInfo()
return (
<div className="border-fill-tertiary from-background to-fill-quaternary relative overflow-hidden rounded-xl border bg-gradient-to-br">
<div className="from-accent/5 absolute inset-0 bg-gradient-to-br to-transparent" />
<div className="relative p-6">
<StatusHeader
title="Current Status"
description={statusInfo.title}
icon={statusInfo.icon}
iconBg={statusInfo.iconBg}
/>
<ReferralProgress
validInvitationsAmount={validInvitationsAmount}
requiredInvitationsAmount={requiredInvitationsAmount}
/>
{role === UserRole.PreProTrial && (
<UpgradeSection skipPrice={skipPrice} onUpgrade={onUpgrade} />
)}
</div>
</div>
)
}
// Reusable StatusHeader Component
interface StatusHeaderProps {
title: string
description: string
icon: string
iconBg: string
}
const StatusHeader = ({ title, description, icon, iconBg }: StatusHeaderProps) => (
<div className="mb-4 flex items-center gap-3">
<div className={cn("flex size-8 items-center justify-center rounded-full text-white", iconBg)}>
<i className={cn("text-sm", icon)} />
</div>
<div>
<h3 className="font-medium">{title}</h3>
<p className="text-text-secondary text-sm">{description}</p>
</div>
</div>
)
// Reusable ReferralProgress Component
interface ReferralProgressProps {
validInvitationsAmount: number
requiredInvitationsAmount: number
}
const ReferralProgress = ({
validInvitationsAmount,
requiredInvitationsAmount,
}: ReferralProgressProps) => (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Referral Progress</span>
<span className="text-text-secondary text-sm">
{validInvitationsAmount} / {requiredInvitationsAmount}
</span>
</div>
<div className="relative">
<div className="bg-fill-tertiary h-2 overflow-hidden rounded-full">
<div
className="from-accent to-accent/70 h-full bg-gradient-to-r transition-all duration-500 ease-out"
style={{
width: `${Math.min((validInvitationsAmount / requiredInvitationsAmount) * 100, 100)}%`,
}}
/>
</div>
{validInvitationsAmount >= requiredInvitationsAmount && <CompletionBadge />}
</div>
</div>
)
// Reusable CompletionBadge Component
const CompletionBadge = () => (
<div className="bg-green absolute -top-1 right-0 flex size-4 items-center justify-center rounded-full text-white">
<i className="i-mgc-check-cute-re text-xs" />
</div>
)
// Reusable UpgradeSection Component
interface UpgradeSectionProps {
skipPrice: number
onUpgrade: () => void
}
const UpgradeSection = ({ skipPrice, onUpgrade }: UpgradeSectionProps) => {
const setTab = useSetSettingTab()
return (
<div className="border-fill-tertiary border-t pt-4">
<div className="flex items-center justify-end gap-4">
<Button
size="sm"
buttonClassName="bg-gradient-to-r from-accent to-accent/80 hover:from-accent/90 hover:to-accent/70"
onClick={() => {
setTab("referral")
}}
>
Invite 3 friends
</Button>
<span>or</span>
<Button
size="sm"
buttonClassName="bg-gradient-to-r from-accent to-accent/80 hover:from-accent/90 hover:to-accent/70"
onClick={onUpgrade}
>
Pay ${skipPrice}
</Button>
</div>
</div>
)
}
// Reusable PlanCard Component
interface PlanCardProps {
plan: Plan
currentUserRole: UserRole | null
isCurrentPlan: boolean
daysLeft: number | null
}
const PlanCard = ({ plan, currentUserRole, isCurrentPlan, daysLeft }: PlanCardProps) => {
const getPlanActionType = (): "current" | "upgrade" | "coming-soon" | "in-trial" | null => {
if (plan.isComingSoon) return "coming-soon"
if (!currentUserRole) {
return plan.tier > PLAN_TIER_MAP[UserRole.Free] ? "upgrade" : "current"
}
const currentTier = PLAN_TIER_MAP[currentUserRole]
const targetTier = plan.tier
if (currentTier === targetTier) {
if (currentUserRole === UserRole.PreProTrial) {
return "in-trial"
} else {
return "current"
}
}
if (targetTier > currentTier) return "upgrade"
return null
}
const actionType = getPlanActionType()
return (
<div
className={cn(
"group relative flex h-full flex-col overflow-hidden rounded-xl border transition-all duration-200",
plan.isPopular
? "border-accent"
: "border-fill-tertiary bg-background hover:border-fill-secondary",
isCurrentPlan &&
"ring-accent ring-offset-background from-accent/5 shadow-accent/10 bg-gradient-to-b to-transparent shadow-lg ring-2 ring-offset-2",
plan.isComingSoon && "opacity-75",
)}
>
<PlanBadges isPopular={plan.isPopular || false} />
<div className="@md:p-5 flex h-full flex-col p-4">
<div className="@md:space-y-4 flex-1 space-y-3">
<PlanHeader title={plan.title} price={plan.price} period={plan.period} />
<PlanFeatures features={plan.features} />
<div />
</div>
<PlanAction
isPopular={plan.isPopular || false}
actionType={actionType}
daysLeft={daysLeft}
onSelect={async () => {
if (
!plan.isComingSoon &&
!isCurrentPlan && // Handle plan selection logic
plan.role === UserRole.PrePro
) {
// Trigger upgrade to Pro Preview
const res = await subscription.upgrade({
plan: "folo pro preview",
successUrl: env.VITE_WEB_URL,
cancelUrl: env.VITE_WEB_URL,
disableRedirect: IN_ELECTRON,
})
if (IN_ELECTRON && res.data?.url) {
window.open(res.data.url, "_blank")
}
}
// Add other plan selection logic as needed
}}
/>
</div>
{/* Subtle gradient line at bottom */}
<div className="via-fill-tertiary absolute inset-x-0 bottom-0 h-px bg-gradient-to-r from-transparent to-transparent" />
</div>
)
}
// Plan card sub-components
const PlanBadges = ({ isPopular }: { isPopular: boolean }) => (
<>
{isPopular && (
<div className="absolute -top-px right-4 z-10">
<div className="from-accent to-accent/80 text-caption rounded-b-lg bg-gradient-to-r px-1.5 py-1 font-medium text-white shadow-sm">
Most Popular
</div>
</div>
)}
</>
)
const PlanHeader = ({ title, price, period }: { title: string; price: string; period: string }) => (
<div className="space-y-1">
<h3 className="@md:text-lg text-base font-semibold">{title}</h3>
<div className="flex items-baseline gap-1">
<span className="font-default text-xl font-bold">{price}</span>
{period && <span className="text-text-secondary @md:text-sm text-xs">/{period}</span>}
</div>
</div>
)
const PlanFeatures = ({ features }: { features: string[] }) => (
<div className="@md:space-y-2 space-y-1.5">
{features.map((feature, index) => (
<div key={index} className="@md:gap-3 flex items-start gap-2.5">
<div className="bg-green/10 @md:size-4 mt-0.5 flex size-3.5 items-center justify-center rounded-full">
<i className="i-mgc-check-cute-re text-green @md:text-xs text-[10px]" />
</div>
<span className="@md:text-sm text-xs leading-relaxed">{feature}</span>
</div>
))}
</div>
)
const PlanAction = ({
isPopular,
actionType,
onSelect,
daysLeft,
}: {
isPopular: boolean
actionType: "current" | "upgrade" | "coming-soon" | "in-trial" | null
onSelect?: () => void
daysLeft: number | null
}) => {
const getButtonConfig = () => {
switch (actionType) {
case "coming-soon": {
return {
text: "Coming Soon",
variant: "outline" as const,
className: "w-full h-9 @md:h-10 text-xs @md:text-sm",
disabled: true,
}
}
case "current": {
return {
text: "Current Plan",
variant: "outline" as const,
className: "w-full h-9 @md:h-10 text-xs @md:text-sm text-text-secondary",
disabled: true,
}
}
case "in-trial": {
return {
text: `In Trial (${daysLeft} days left)`,
variant: "outline" as const,
className: "w-full h-9 @md:h-10 text-xs @md:text-sm",
disabled: false,
}
}
case "upgrade": {
return {
text: "Upgrade",
variant: isPopular ? undefined : ("outline" as const),
className: isPopular
? "w-full h-9 @md:h-10 text-xs @md:text-sm bg-gradient-to-r from-accent to-accent/80 hover:from-accent/90 hover:to-accent/70"
: "w-full h-9 @md:h-10 text-xs @md:text-sm",
disabled: false,
}
}
case null: {
return null
}
}
}
const buttonConfig = getButtonConfig()
if (!buttonConfig) {
return null
}
return (
<div>
<Button
variant={buttonConfig.variant}
buttonClassName={buttonConfig.className}
disabled={buttonConfig.disabled}
onClick={buttonConfig.disabled ? undefined : onSelect}
>
{buttonConfig.text}
</Button>
</div>
)
}

View File

@ -0,0 +1,139 @@
import { Card } from "@follow/components/ui/card/index.js"
import { Divider } from "@follow/components/ui/divider/Divider.js"
import { Progress } from "@follow/components/ui/progress/index.js"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@follow/components/ui/table/index.jsx"
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@follow/components/ui/tooltip/index.js"
import { UserRole, UserRoleName } from "@follow/constants"
import { env } from "@follow/shared/env.desktop"
import { useUserRole, useWhoami } from "@follow/store/user/hooks"
import dayjs from "dayjs"
import { Trans, useTranslation } from "react-i18next"
import { useServerConfigs } from "~/atoms/server-configs"
import { CopyButton } from "~/components/ui/button/CopyButton"
import { usePresentUserProfileModal } from "~/modules/profile/hooks"
import { UserAvatar } from "~/modules/user/UserAvatar"
import { useReferralInfo } from "~/queries/referral"
export function SettingReferral() {
const { t } = useTranslation("settings")
const serverConfigs = useServerConfigs()
const requiredInvitationsAmount = serverConfigs?.REFERRAL_REQUIRED_INVITATIONS || 3
const ruleLink = serverConfigs?.REFERRAL_RULE_LINK
const { data: referralInfo } = useReferralInfo()
const validInvitationsAmount = referralInfo?.invitations.filter((i) => i.usedAt).length || 0
const user = useWhoami()
const role = useUserRole()
const referralLink = `${env.VITE_WEB_URL}/register?referral=${user?.handle || user?.id}`
const presentUserProfile = usePresentUserProfileModal("drawer")
return (
<section className="mt-4">
<div className="mb-4 space-y-2 text-sm">
<p>
<Trans
ns="settings"
i18nKey="referral.description"
values={{
day: referralInfo?.referralCycleDays || 45,
}}
components={{
Link: <a href={ruleLink} className="text-accent" target="_blank" />,
}}
/>
</p>
</div>
<Divider className="my-6" />
<p className="my-2 font-semibold">{t("referral.link")}</p>
<Card className="flex items-center gap-2 px-3 py-1.5">
<span>{referralLink}</span>
<CopyButton variant="outline" value={referralLink} />
</Card>
{role !== UserRole.PrePro && (
<div className="mt-4 space-y-2">
<p className="font-semibold">
Referral Progress for the Free {UserRoleName[UserRole.PrePro]}:
</p>
<div className="flex items-center gap-4">
<Progress value={(validInvitationsAmount / requiredInvitationsAmount) * 100} />
<span className="shrink-0">
{validInvitationsAmount} / {requiredInvitationsAmount}
</span>
</div>
</div>
)}
{!!referralInfo?.invitations.length && (
<>
<Divider className="my-6" />
<p className="font-semibold">Your Invited Friends</p>
<Table className="mt-4">
<TableHeader>
<TableRow className="[&_*]:!font-semibold">
<TableHead size="sm">Friend (Email/ID)</TableHead>
<TableHead size="sm">Joined On</TableHead>
<TableHead size="sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody className="border-t-[12px] border-transparent [&_td]:!px-3">
{referralInfo?.invitations?.map((row) => (
<TableRow key={row.code} className="h-8">
<TableCell size="sm">
<Tooltip>
<TooltipTrigger>
<button
type="button"
className="cursor-pointer"
onClick={() => {
presentUserProfile(row.user?.id)
}}
>
<UserAvatar
userId={row.user?.id}
className="h-auto p-0"
avatarClassName="size-5"
hideName
/>
</button>
</TooltipTrigger>
{row.user?.name && (
<TooltipPortal>
<TooltipContent>{row.user?.name}</TooltipContent>
</TooltipPortal>
)}
</Tooltip>
</TableCell>
<TableCell size="sm">{dayjs(row.createdAt).format("MMMM D, YYYY")}</TableCell>
<TableCell size="sm">
{row.usedAt ? (
t("referral.invited_friend_status.valid")
) : (
<Tooltip>
<TooltipTrigger>
{t("referral.invited_friend_status.pending")}
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>Active status are refreshed daily</TooltipContent>
</TooltipPortal>
</Tooltip>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</>
)}
</section>
)
}

View File

@ -39,13 +39,15 @@ export const SettingsTitle = ({
const { t } = useTranslation("settings")
const {
icon: iconName,
name: title,
name,
title,
headerIcon,
} = (useLoaderData() || loader || {}) as SettingPageConfig
const usedIcon = headerIcon || iconName
const usedTitle = title || name
const isInSettingIndependentWindow = use(IsInSettingIndependentWindowContext)
if (!title) {
if (!usedTitle) {
return null
}
return (
@ -58,7 +60,7 @@ export const SettingsTitle = ({
)}
>
{typeof usedIcon === "string" ? <i className={usedIcon} /> : usedIcon}
<span>{t(title as any)}</span>
<span>{t(usedTitle)}</span>
</div>
)
}

View File

@ -14,6 +14,7 @@ export enum DisableWhy {
export interface SettingPageConfig {
icon: string | React.ReactNode
name: I18nKeysForSettings
title?: I18nKeysForSettings
priority: number
headerIcon?: string | React.ReactNode
hideIf?: (ctx: SettingPageContext, serverConfigs?: ServerConfigs | null) => boolean

View File

@ -13,7 +13,7 @@ import { useTranslation } from "react-i18next"
import { useNavigate } from "react-router"
import rsshubLogoUrl from "~/assets/rsshub-icon.png?url"
import { useIsInMASReview } from "~/atoms/server-configs"
import { useIsInMASReview, useServerConfigs } from "~/atoms/server-configs"
import { useIsZenMode, useSetZenMode } from "~/atoms/settings/ui"
import {
DropdownMenu,
@ -35,6 +35,7 @@ import { useCommandShortcuts } from "../command/hooks/use-command-binding"
import type { LoginProps } from "./LoginButton"
import { LoginButton } from "./LoginButton"
import { UserAvatar } from "./UserAvatar"
import { UserProBadge } from "./UserProBadge"
const rsshubLogo = new URL(rsshubLogoUrl, import.meta.url).href
@ -43,6 +44,7 @@ export type ProfileButtonProps = LoginProps & {
}
export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
const serverConfig = useServerConfigs()
const { status, session } = useSession()
const { user } = session || {}
const settingModalPresent = useSettingModal()
@ -89,12 +91,25 @@ export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
<EllipsisHorizontalTextWithTooltip className="mx-auto max-w-[20ch] truncate text-lg">
{user?.name}
</EllipsisHorizontalTextWithTooltip>
{!!user?.handle && (
<a href={UrlBuilder.profile(user.handle)} target="_blank" className="block">
<EllipsisHorizontalTextWithTooltip className="mt-0.5 truncate text-xs font-medium text-zinc-500">
@{user.handle}
</EllipsisHorizontalTextWithTooltip>
</a>
{serverConfig?.REFERRAL_ENABLED ? (
<UserProBadge
role={role}
withText
className="mt-0.5 w-full justify-center"
onClick={() => {
settingModalPresent("plan")
}}
/>
) : (
<>
{!!user?.handle && (
<a href={UrlBuilder.profile(user.handle)} target="_blank" className="block">
<EllipsisHorizontalTextWithTooltip className="mt-0.5 truncate text-xs font-medium text-zinc-500">
@{user.handle}
</EllipsisHorizontalTextWithTooltip>
</a>
)}
</>
)}
</div>
</DropdownMenuLabel>
@ -114,7 +129,7 @@ export const ProfileButton: FC<ProfileButtonProps> = memo((props) => {
<DropdownMenuItem
className="pl-3"
onClick={() => {
if (role !== UserRole.Trial) {
if (role !== UserRole.Trial && role !== UserRole.Free) {
presentAchievement()
} else {
presentActivationModal()

View File

@ -1,14 +1,17 @@
import { Avatar, AvatarFallback, AvatarImage } from "@follow/components/ui/avatar/index.jsx"
import { usePrefetchUser, useUserById, useWhoami } from "@follow/store/user/hooks"
import { UserRole } from "@follow/constants"
import { usePrefetchUser, useUserById, useUserRole, useWhoami } from "@follow/store/user/hooks"
import { getColorScheme, stringToHue } from "@follow/utils/color"
import { cn } from "@follow/utils/utils"
import { useServerConfigs } from "~/atoms/server-configs"
import { replaceImgUrlIfNeed } from "~/lib/img-proxy"
import { usePresentUserProfileModal } from "~/modules/profile/hooks"
import { useSession } from "~/queries/auth"
import type { LoginProps } from "./LoginButton"
import { LoginButton } from "./LoginButton"
import { UserProBadge } from "./UserProBadge"
export const UserAvatar = ({
ref,
@ -28,10 +31,13 @@ export const UserAvatar = ({
enableModal?: boolean
} & LoginProps &
React.HTMLAttributes<HTMLDivElement> & { ref?: React.Ref<HTMLDivElement | null> }) => {
const serverConfig = useServerConfigs()
const { status } = useSession({
enabled: !userId,
})
const whoami = useWhoami()
const role = useUserRole()
const presentUserProfile = usePresentUserProfileModal("drawer")
usePrefetchUser(userId)
@ -55,7 +61,7 @@ export const UserAvatar = ({
}}
{...props}
className={cn(
"text-text-secondary flex h-20 items-center justify-center gap-2 px-5 py-2 font-medium",
"text-text-secondary relative flex h-20 items-center justify-center gap-2 px-5 py-2 font-medium",
className,
)}
>
@ -76,6 +82,16 @@ export const UserAvatar = ({
{renderUserData?.name?.[0]}
</AvatarFallback>
</Avatar>
{serverConfig?.REFERRAL_ENABLED &&
!userId &&
role !== UserRole.Free &&
role !== UserRole.Trial && (
<UserProBadge
className="absolute bottom-0 right-0 -mb-[6%] -mr-[6%] size-2/5 max-h-5 max-w-5"
iconClassName="size-full"
role={role}
/>
)}
{!hideName && <div>{renderUserData?.name || renderUserData?.handle}</div>}
</div>
)

View File

@ -0,0 +1,34 @@
import { UserRole, UserRoleName } from "@follow/constants"
import { cn } from "@follow/utils"
export const UserProBadge = ({
className,
withText,
iconClassName,
role,
onClick,
}: {
className?: string
withText?: boolean
iconClassName?: string
role?: UserRole | null
onClick?: () => void
}) => {
if (!role) {
return null
}
return (
<div
className={cn(
"flex items-center gap-1",
role === UserRole.Trial || role === UserRole.Free ? "text-text-secondary" : "text-accent",
className,
)}
onClick={onClick}
>
<i className={cn("i-mgc-power block", iconClassName)} />
{withText && <span className="text-xs">{UserRoleName[role]}</span>}
</div>
)
}

View File

@ -67,7 +67,7 @@ export function Component() {
const { onUpdateMaxScroll } = useScrollElementUpdate()
const currentTabs = tabs.map((tab) => {
const disabled = tab.disableForTrial && role === UserRole.Trial
const disabled = tab.disableForTrial && (role === UserRole.Free || role === UserRole.Trial)
return {
...tab,
disabled,

View File

@ -11,8 +11,11 @@ export const loader = defineSettingPageData({
icon: iconName,
name: "titles.invitations",
priority,
disableIf: (ctx) => [ctx.role === UserRole.Trial, DisableWhy.NotActivation],
hideIf: (ctx, serverConfigs) => ctx.isInMASReview || !serverConfigs?.INVITATION_ENABLED,
disableIf: (ctx) => [
ctx.role === UserRole.Free || ctx.role === UserRole.Trial,
DisableWhy.NotActivation,
],
hideIf: (_, serverConfigs) => !serverConfigs?.INVITATION_ENABLED,
})
export function Component() {

View File

@ -11,7 +11,10 @@ export const loader = defineSettingPageData({
icon: iconName,
name: "titles.lists",
priority,
disableIf: (ctx) => [ctx.role === UserRole.Trial, DisableWhy.NotActivation],
disableIf: (ctx) => [
ctx.role === UserRole.Free || ctx.role === UserRole.Trial,
DisableWhy.NotActivation,
],
hideIf: (ctx) => ctx.isInMASReview,
})

View File

@ -11,7 +11,10 @@ export const loader = defineSettingPageData({
icon: iconName,
name: "titles.notifications",
priority,
disableIf: (ctx) => [ctx.role === UserRole.Trial, DisableWhy.NotActivation],
disableIf: (ctx) => [
ctx.role === UserRole.Free || ctx.role === UserRole.Trial,
DisableWhy.NotActivation,
],
})
export function Component() {

View File

@ -0,0 +1,23 @@
import { SettingPlan } from "~/modules/settings/tabs/plan"
import { SettingsTitle } from "~/modules/settings/title"
import { defineSettingPageData } from "~/modules/settings/utils"
const iconName = "i-mgc-power-outline"
const priority = (1000 << 2) + 30
export const loader = defineSettingPageData({
icon: iconName,
name: "titles.plan.short",
title: "titles.plan.long",
priority,
hideIf: (ctx, serverConfigs) => ctx.isInMASReview || !serverConfigs?.REFERRAL_ENABLED,
})
export function Component() {
return (
<>
<SettingsTitle />
<SettingPlan />
</>
)
}

View File

@ -0,0 +1,23 @@
import { SettingReferral } from "~/modules/settings/tabs/referral"
import { SettingsTitle } from "~/modules/settings/title"
import { defineSettingPageData } from "~/modules/settings/utils"
const iconName = "i-mgc-love-cute-re"
const priority = (1000 << 2) + 40
export const loader = defineSettingPageData({
icon: iconName,
name: "titles.referral.short",
title: "titles.referral.long",
priority,
hideIf: (ctx, serverConfigs) => ctx.isInMASReview || !serverConfigs?.REFERRAL_ENABLED,
})
export function Component() {
return (
<>
<SettingsTitle />
<SettingReferral />
</>
)
}

View File

@ -1,16 +1,65 @@
import { useEffect } from "react"
import { UserRole, UserRoleName } from "@follow/constants"
import { getStorageNS } from "@follow/utils/ns"
import { useEffect, useMemo } from "react"
import { toast } from "sonner"
import { setIntegrationIdentify } from "~/initialize/helper"
import { useSettingModal } from "~/modules/settings/modal/useSettingModal"
import { useSession } from "~/queries/auth"
export const UserProvider = () => {
const { session } = useSession()
const settingModalPresent = useSettingModal()
useEffect(() => {
if (!session?.user) return
// @ts-expect-error FIXME
setIntegrationIdentify(session.user)
}, [session?.role, session?.user])
}, [session?.user])
const roleEndDate = useMemo(
() =>
session?.roleEndAt
? typeof session.roleEndAt === "string"
? new Date(session.roleEndAt)
: session.roleEndAt
: undefined,
[session?.roleEndAt],
)
useEffect(() => {
if (!session?.role) return
const itemKey = getStorageNS("pro-preview-toast-dismissed")
const isToastDismissed = localStorage.getItem(itemKey)
if (session.role && session.role !== UserRole.PrePro && !isToastDismissed) {
const message =
session.role === UserRole.Free || session.role === UserRole.Trial
? `You are currently on the ${UserRoleName[UserRole.Free]} plan. Some features may be limited.`
: session.role === UserRole.PreProTrial
? `You are currently on the ${UserRoleName[UserRole.PreProTrial]} plan.${roleEndDate ? ` It will end on ${roleEndDate.toLocaleDateString()}.` : ""}`
: ""
if (!message) {
localStorage.setItem(itemKey, "true")
return
}
toast.warning(message, {
duration: Number.POSITIVE_INFINITY,
action: {
label: "More",
onClick: () => {
settingModalPresent("referral")
localStorage.setItem(itemKey, "true")
},
},
onDismiss: () => {
localStorage.setItem(itemKey, "true")
},
})
}
}, [roleEndDate, session?.role, settingModalPresent])
return null
}

View File

@ -0,0 +1,21 @@
import { useAuthQuery } from "~/hooks/common/useBizQuery"
import { apiClient } from "~/lib/api-fetch"
import { defineQuery } from "~/lib/defineQuery"
export const referral = {
get: () =>
defineQuery(
["referral"],
async () => {
const res = await apiClient.referrals.$get()
return res.data
},
{
rootKey: ["referral"],
},
),
}
export function useReferralInfo() {
return useAuthQuery(referral.get())
}

View File

@ -1,12 +1,11 @@
import { useAuthQuery } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { defineQuery } from "~/lib/defineQuery"
import { useQuery } from "@tanstack/react-query"
export const serverConfigs = {
get: () => defineQuery(["server-configs"], async () => await apiClient.status.configs.$get()),
}
import { apiClient } from "~/lib/api-fetch"
export const useServerConfigsQuery = () => {
const { data } = useAuthQuery(serverConfigs.get())
const { data } = useQuery({
queryKey: ["server-configs"],
queryFn: () => apiClient.status.configs.$get(),
})
return data?.data
}

View File

@ -0,0 +1,95 @@
{
"appPolicies" : {
"eula" : "",
"policies" : [
{
"locale" : "en_US",
"policyText" : "",
"policyURL" : ""
}
]
},
"identifier" : "33F52C22",
"nonRenewingSubscriptions" : [
],
"products" : [
{
"displayPrice" : "1.0",
"familyShareable" : false,
"internalID" : "6747998552",
"localizations" : [
{
"description" : "Pro Preview",
"displayName" : "Pro Preview",
"locale" : "en_US"
}
],
"productID" : "is.follow.propreview",
"referenceName" : "Pro Preview",
"type" : "NonConsumable"
}
],
"settings" : {
"_applicationInternalID" : "6739802604",
"_developerTeamID" : "492J8Q67PF",
"_failTransactionsEnabled" : false,
"_lastSynchronizedDate" : 773246215.31770396,
"_locale" : "en_US",
"_storefront" : "USA",
"_storeKitErrors" : [
{
"current" : null,
"enabled" : false,
"name" : "Load Products"
},
{
"current" : null,
"enabled" : false,
"name" : "Purchase"
},
{
"current" : null,
"enabled" : false,
"name" : "Verification"
},
{
"current" : null,
"enabled" : false,
"name" : "App Store Sync"
},
{
"current" : null,
"enabled" : false,
"name" : "Subscription Status"
},
{
"current" : null,
"enabled" : false,
"name" : "App Transaction"
},
{
"current" : null,
"enabled" : false,
"name" : "Manage Subscriptions Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Refund Request Sheet"
},
{
"current" : null,
"enabled" : false,
"name" : "Offer Code Redeem Sheet"
}
]
},
"subscriptionGroups" : [
],
"version" : {
"major" : 4,
"minor" : 0
}
}

View File

@ -13,6 +13,7 @@
21D47042A21541638A8813FF /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 771F66ABFAD64B3D89ABAB8A /* GoogleService-Info.plist */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
948BDAD26FE9D66B8735BBD0 /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CEF90D1FA529DB2CC2AE941 /* ExpoModulesProvider.swift */; };
A6C8B6012E13C44000D28090 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6C8B6002E13C44000D28090 /* StoreKit.framework */; };
A6D4133120E003E1031C2B48 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = BB62D7D685AF319E0A030204 /* PrivacyInfo.xcprivacy */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
@ -27,6 +28,8 @@
5D1548572A564A709411B1D2 /* Pods-Folo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Folo.release.xcconfig"; path = "Target Support Files/Pods-Folo/Pods-Folo.release.xcconfig"; sourceTree = "<group>"; };
771F66ABFAD64B3D89ABAB8A /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Folo/GoogleService-Info.plist"; sourceTree = "<group>"; };
84F18C4CB344992039541AAC /* Pods-Folo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Folo.debug.xcconfig"; path = "Target Support Files/Pods-Folo/Pods-Folo.debug.xcconfig"; sourceTree = "<group>"; };
A652EE6C2E16CCFF00371A74 /* Folo - Follow everything.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; path = "Folo - Follow everything.storekit"; sourceTree = "<group>"; };
A6C8B6002E13C44000D28090 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Folo/SplashScreen.storyboard; sourceTree = "<group>"; };
B69598E8ACBE445EA3C3DCEE /* rn-web */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "rn-web"; path = "../../../out/rn-web"; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
@ -42,6 +45,7 @@
buildActionMask = 2147483647;
files = (
08804409FAE316B2F4286290 /* Pods_Folo.framework in Frameworks */,
A6C8B6012E13C44000D28090 /* StoreKit.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -74,6 +78,7 @@
2D16E6871FA4F8E400B85C8A /* Frameworks */ = {
isa = PBXGroup;
children = (
A6C8B6002E13C44000D28090 /* StoreKit.framework */,
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
2C9A37CAFE9FFED2FCC141E3 /* Pods_Folo.framework */,
);
@ -115,6 +120,7 @@
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
A652EE6C2E16CCFF00371A74 /* Folo - Follow everything.storekit */,
13B07FAE1A68108700A75B9A /* Folo */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */,

View File

@ -60,6 +60,9 @@
ReferencedContainer = "container:Folo.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../Folo - Follow everything.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

View File

@ -66,6 +66,7 @@
"expo-document-picker": "13.1.6",
"expo-file-system": "18.1.10",
"expo-haptics": "14.1.4",
"expo-iap": "2.5.0",
"expo-image": "2.3.0",
"expo-image-manipulator": "13.1.7",
"expo-image-picker": "~16.1.4",

View File

@ -1,10 +1,13 @@
import { UserRole } from "@follow/constants"
import { cn } from "@follow/utils/utils"
import type { Image as ExpoImage } from "expo-image"
import { useCallback } from "react"
import { Text, TouchableOpacity, View } from "react-native"
import { measure, runOnJS, runOnUI, useAnimatedRef } from "react-native-reanimated"
import { PowerIcon } from "@/src/icons/power"
import { User4CuteFiIcon } from "@/src/icons/user_4_cute_fi"
import { accentColor } from "@/src/theme/colors"
import { useLightboxControls } from "../../lightbox/lightboxState"
import { Image } from "../image/Image"
@ -15,8 +18,8 @@ interface UserAvatarProps {
name?: string | null
className?: string
color?: string
preview?: boolean
role?: UserRole | null
}
export const UserAvatar = ({
@ -26,6 +29,7 @@ export const UserAvatar = ({
className,
color,
preview = true,
role,
}: UserAvatarProps) => {
const { openLightbox } = useLightboxControls()
const aviRef = useAnimatedRef<ExpoImage>()
@ -58,6 +62,16 @@ export const UserAvatar = ({
})()
}, [aviRef, image, openLightbox])
const avatarBadge =
role && role !== UserRole.Free && role !== UserRole.Trial ? (
<View
className="absolute bottom-0 right-0 rounded-full"
style={{ width: size / 3, height: size / 3 }}
>
<PowerIcon color={accentColor} width={size / 3} height={size / 3} />
</View>
) : null
if (!image) {
return (
<View
@ -79,21 +93,25 @@ export const UserAvatar = ({
) : (
<User4CuteFiIcon width={size} height={size} color={color} />
)}
{avatarBadge}
</View>
)
}
const imageContent = (
<Image
ref={aviRef}
source={{ uri: image }}
className={cn("rounded-full", className)}
style={{ width: size, height: size }}
proxy={{
width: size,
height: size,
}}
/>
<View className="relative">
<Image
ref={aviRef}
source={{ uri: image }}
className={cn("rounded-full", className)}
style={{ width: size, height: size }}
proxy={{
width: size,
height: size,
}}
/>
{avatarBadge}
</View>
)
return preview ? (

View File

@ -0,0 +1,24 @@
import * as React from "react"
import Svg, { Path } from "react-native-svg"
interface CheckCuteReIconProps {
width?: number
height?: number
color?: string
}
export const CheckCuteReIcon = ({
width = 24,
height = 24,
color = "#10161F",
}: CheckCuteReIconProps) => {
return (
<Svg width={width} height={height} fill="none" viewBox="0 0 24 24">
<Path
d="M20.239 5.214c-.1.027-.397.172-.66.323-3.769 2.156-6.552 4.587-8.958 7.823-.418.562-1.212 1.739-1.441 2.134l-.112.194-.104-.134c-.536-.691-1.843-2.06-2.644-2.771-.871-.774-2.093-1.717-2.42-1.867-.338-.156-.845-.051-1.108.229a1.013 1.013 0 0 0-.103 1.242c.05.075.388.358.751.63.808.605 1.134.876 1.88 1.565.963.89 1.785 1.809 2.665 2.978.272.363.556.701.629.751a.976.976 0 0 0 .966.074c.274-.13.365-.239.731-.885 1.674-2.95 3.645-5.33 6.149-7.424 1.116-.933 2.733-2.037 4.097-2.796.527-.294.696-.429.809-.651.385-.756-.316-1.636-1.127-1.415"
fill={color}
fillRule="evenodd"
/>
</Svg>
)
}

View File

@ -0,0 +1,24 @@
import * as React from "react"
import Svg, { Path } from "react-native-svg"
interface PasteCuteReIconProps {
width?: number
height?: number
color?: string
}
export const PasteCuteReIcon = ({
width = 24,
height = 24,
color = "#10161F",
}: PasteCuteReIconProps) => {
return (
<Svg width={width} height={height} fill="none" viewBox="0 0 24 24">
<Path
d="M7.38 1.576c-.449.112-.967.431-1.257.775-.145.173-.183.195-.422.24-1.331.252-2.539 1.305-2.973 2.589-.211.627-.208.544-.208 4.948.001 2.422.017 4.288.041 4.572.1 1.173.331 1.906.839 2.663.222.331.906 1.015 1.237 1.237.983.66 1.978.88 3.987.88h.772l.171.329c.226.435.577.903.954 1.272.745.73 1.775 1.232 2.779 1.355.577.07 3.497.058 4.005-.018 1.817-.269 3.367-1.6 3.952-3.392.2-.612.223-.954.223-3.251 0-2.242-.021-2.598-.185-3.175a5.927 5.927 0 0 0-.704-1.469 5.287 5.287 0 0 0-1.78-1.563l-.326-.169-.015-1.81c-.017-1.951-.014-1.914-.252-2.55-.454-1.21-1.604-2.18-2.898-2.445-.242-.05-.279-.073-.541-.334a2.562 2.562 0 0 0-.962-.63l-.237-.09-3-.007c-2.288-.006-3.047.004-3.2.043m5.871 1.995a.501.501 0 0 1 0 .858c-.099.066-.299.072-2.609.083-1.376.007-2.578.001-2.672-.012-.554-.08-.633-.818-.105-.979.063-.019 1.277-.032 2.697-.028 2.392.006 2.589.012 2.689.078M5.695 4.97c.145.34.454.748.734.97.278.221.713.436 1.003.496.298.062 5.844.061 6.136-.001.708-.15 1.358-.683 1.7-1.395.079-.165.149-.306.155-.314.025-.031.368.236.54.42.222.237.367.481.46.774.061.188.073.427.087 1.645l.017 1.426-1.574.021c-1.683.023-1.908.045-2.531.249-.732.238-1.352.628-1.943 1.218-.59.591-.98 1.211-1.218 1.943-.217.663-.228.802-.249 3.018l-.02 2.06-.886-.026c-.88-.026-1.392-.089-1.747-.214-.75-.264-1.479-1.057-1.675-1.823-.166-.652-.18-1.06-.182-5.257-.002-3.764.002-4.014.071-4.24a1.95 1.95 0 0 1 .443-.774c.152-.166.493-.443.548-.445.013-.001.072.112.131.249m11.565 6.125a3.093 3.093 0 0 1 2.118 2.065c.126.415.18 2.616.102 4.22-.03.644-.051.804-.136 1.05-.323.941-.972 1.59-1.914 1.915-.257.088-.398.104-1.23.142-1.153.052-2.694-.003-3.04-.109a3.013 3.013 0 0 1-1.842-1.558c-.289-.588-.288-.579-.308-2.707-.02-2.207.006-2.656.187-3.153a2.97 2.97 0 0 1 1.766-1.764c.469-.171.757-.191 2.517-.178 1.278.01 1.581.023 1.78.077"
fill={color}
fillRule="evenodd"
/>
</Svg>
)
}

View File

@ -8,6 +8,7 @@ import { createAuthClient } from "better-auth/react"
import { nativeApplicationVersion } from "expo-application"
import * as FileSystem from "expo-file-system"
import * as SecureStore from "expo-secure-store"
import Storage from "expo-sqlite/kv-store"
import { Platform } from "react-native"
import DeviceInfo from "react-native-device-info"
@ -63,6 +64,15 @@ export const authClient = createAuthClient({
ctx.headers.set(key, value)
})
ctx.headers.set("User-Agent", await getUserAgent())
const value = Storage.getItemSync("referral-code")
if (value) {
const referralCode = JSON.parse(value)
if (referralCode) {
ctx.headers.set("folo-referral-code", referralCode)
}
}
return ctx
},
headers: {

View File

@ -9,6 +9,7 @@ import { Alert, Text, TouchableOpacity, View } from "react-native"
import { KeyboardController } from "react-native-keyboard-controller"
import { z } from "zod"
import { useServerConfigs } from "@/src/atoms/server-configs"
import { SubmitButton } from "@/src/components/common/SubmitButton"
import { PlainTextField } from "@/src/components/ui/form/TextField"
import { signIn, signUp } from "@/src/lib/auth"
@ -20,6 +21,8 @@ import { ForgetPasswordScreen } from "@/src/screens/(modal)/ForgetPasswordScreen
import { TwoFactorAuthScreen } from "@/src/screens/(modal)/TwoFactorAuthScreen"
import { accentColor } from "@/src/theme/colors"
import { ReferralForm } from "./referral"
const formSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(128),
@ -170,6 +173,7 @@ function SignupInput({
}
export function EmailSignUp() {
const serverConfigs = useServerConfigs()
const { control, handleSubmit, formState } = useForm<SignupFormValue>({
resolver: zodResolver(signupFormSchema),
defaultValues: {
@ -261,6 +265,12 @@ export function EmailSignUp() {
}}
/>
</View>
{serverConfigs?.REFERRAL_ENABLED && (
<>
<View className="border-b-opaque-separator border-b-hairline" />
<ReferralForm />
</>
)}
</View>
<SubmitButton
disabled={submitMutation.isPending || !formState.isValid}

View File

@ -1,7 +1,7 @@
import { useCallback, useState } from "react"
import { Trans, useTranslation } from "react-i18next"
import { Text, TouchableOpacity, TouchableWithoutFeedback, View } from "react-native"
import { KeyboardController } from "react-native-keyboard-controller"
import { KeyboardAvoidingView, KeyboardController } from "react-native-keyboard-controller"
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated"
import { useSafeAreaInsets } from "react-native-safe-area-context"
import * as ContextMenu from "zeego/context-menu"
@ -30,63 +30,65 @@ export function Login() {
return (
<View className="pb-safe-or-2 flex-1 justify-between" style={{ paddingTop: insets.top + 56 }}>
<TouchableWithoutFeedback
onPress={() => {
KeyboardController.dismiss()
}}
accessible={false}
>
<View
className="items-center"
style={{
gap: gapSize,
<KeyboardAvoidingView behavior={"position"}>
<TouchableWithoutFeedback
onPress={() => {
KeyboardController.dismiss()
}}
accessible={false}
>
<Logo style={{ width: logoSize, height: logoSize }} />
<Text
className="text-label"
<View
className="items-center"
style={{
fontSize,
lineHeight,
gap: gapSize,
}}
>
<Text className="font-semibold">{`${isRegister ? t("signin.sign_up_to") : t("signin.sign_in_to")} `}</Text>
<Text className="font-bold">Folo</Text>
</Text>
{isEmail ? (
isRegister ? (
<EmailSignUp />
<Logo style={{ width: logoSize, height: logoSize }} />
<Text
className="text-label"
style={{
fontSize,
lineHeight,
}}
>
<Text className="font-semibold">{`${isRegister ? t("signin.sign_up_to") : t("signin.sign_in_to")} `}</Text>
<Text className="font-bold">Folo</Text>
</Text>
{isEmail ? (
isRegister ? (
<EmailSignUp />
) : (
<EmailLogin />
)
) : (
<EmailLogin />
)
<SocialLogin onPressEmail={() => setIsEmail(true)} isRegister={isRegister} />
)}
</View>
</TouchableWithoutFeedback>
<TermsCheckBox />
<View className="mt-14">
{isEmail ? (
<Text
className="text-label pb-2 text-center text-lg font-medium"
onPress={() => setIsEmail(false)}
>
{t("login.back")}
</Text>
) : (
<SocialLogin onPressEmail={() => setIsEmail(true)} />
<TouchableOpacity onPress={() => setIsRegister(!isRegister)}>
<Text className="text-label pb-2 text-center text-lg font-medium">
<Trans
t={t}
i18nKey={isRegister ? "login.have_account" : "login.no_account"}
components={{
strong: <Text className="text-accent" />,
}}
/>
</Text>
</TouchableOpacity>
)}
</View>
</TouchableWithoutFeedback>
<TermsCheckBox />
<View className="mt-14">
{isEmail ? (
<Text
className="text-label pb-2 text-center text-lg font-medium"
onPress={() => setIsEmail(false)}
>
{t("login.back")}
</Text>
) : (
<TouchableOpacity onPress={() => setIsRegister(!isRegister)}>
<Text className="text-label pb-2 text-center text-lg font-medium">
<Trans
t={t}
i18nKey={isRegister ? "login.have_account" : "login.no_account"}
components={{
strong: <Text className="text-accent" />,
}}
/>
</Text>
</TouchableOpacity>
)}
</View>
</KeyboardAvoidingView>
</View>
)
}

View File

@ -0,0 +1,32 @@
import { useAtom } from "jotai"
import { atomWithStorage } from "jotai/utils"
import { View } from "react-native"
import { PlainTextField } from "@/src/components/ui/form/TextField"
import { JotaiPersistSyncStorage } from "@/src/lib/jotai"
import { accentColor } from "@/src/theme/colors"
const referralCodeAtom = atomWithStorage("referral-code", "", JotaiPersistSyncStorage, {
getOnInit: true,
})
export function ReferralForm() {
const [referralCode, setReferralCode] = useAtom(referralCodeAtom)
return (
<View className="flex-row">
<PlainTextField
value={referralCode}
onChangeText={(text) => {
setReferralCode(text)
}}
selectionColor={accentColor}
hitSlop={20}
autoCapitalize="none"
autoCorrect={false}
placeholder="Referral Code (optional)"
className="text-text flex-1"
/>
</View>
)
}

View File

@ -4,11 +4,21 @@ import { useColorScheme } from "nativewind"
import { useTranslation } from "react-i18next"
import { Text, TouchableOpacity, View } from "react-native"
import { useServerConfigs } from "@/src/atoms/server-configs"
import { Image } from "@/src/components/ui/image/Image"
import { PlatformActivityIndicator } from "@/src/components/ui/loading/PlatformActivityIndicator"
import { signIn, useAuthProviders } from "@/src/lib/auth"
export function SocialLogin({ onPressEmail }: { onPressEmail: () => void }) {
import { ReferralForm } from "./referral"
export function SocialLogin({
onPressEmail,
isRegister,
}: {
isRegister: boolean
onPressEmail: () => void
}) {
const serverConfigs = useServerConfigs()
const { data: authProviders, isLoading } = useAuthProviders()
const { colorScheme } = useColorScheme()
const providers = Object.entries(authProviders || [])
@ -87,6 +97,11 @@ export function SocialLogin({ onPressEmail }: { onPressEmail: () => void }) {
</TouchableOpacity>
)
})}
{isRegister && serverConfigs?.REFERRAL_ENABLED && (
<View className="border-opaque-separator border-hairline w-full rounded-xl px-6 py-4">
<ReferralForm />
</View>
)}
</View>
)
}

View File

@ -20,6 +20,7 @@ import { LoveCuteFiIcon } from "@/src/icons/love_cute_fi"
import { Magic2CuteFiIcon } from "@/src/icons/magic_2_cute_fi"
import { NotificationCuteReIcon } from "@/src/icons/notification_cute_re"
import { PaletteCuteFiIcon } from "@/src/icons/palette_cute_fi"
import { PowerOutlineIcon } from "@/src/icons/power_outline"
import { RadaCuteFiIcon } from "@/src/icons/rada_cute_fi"
import { SafeLockFilledIcon } from "@/src/icons/safe_lock_filled"
import { Settings1CuteFiIcon } from "@/src/icons/settings_1_cute_fi"
@ -29,6 +30,7 @@ import { signOut } from "@/src/lib/auth"
import { useNavigation } from "@/src/lib/navigation/hooks"
import type { Navigation } from "@/src/lib/navigation/Navigation"
import { InvitationScreen } from "@/src/screens/(modal)/InvitationScreen"
import { accentColor } from "@/src/theme/colors"
import { AboutScreen } from "./routes/About"
import { AccountScreen } from "./routes/Account"
@ -40,7 +42,9 @@ import { GeneralScreen } from "./routes/General"
import { InvitationsScreen } from "./routes/Invitations"
import { ListsScreen } from "./routes/Lists"
import { NotificationsScreen } from "./routes/Notifications"
import { PlanScreen } from "./routes/Plan"
import { PrivacyScreen } from "./routes/Privacy"
import { ReferralScreen } from "./routes/Referral"
interface GroupNavigationLink {
label: Extract<ParseKeys<"settings">, `titles.${string}`>
@ -113,6 +117,29 @@ const BetaGroupNavigationLinks: GroupNavigationLink[] = [
},
]
const ReferralGroupNavigationLinks: GroupNavigationLink[] = [
{
label: "titles.plan.short",
icon: PowerOutlineIcon,
onPress: ({ navigation }) => {
navigation.pushControllerView(PlanScreen)
},
iconBackgroundColor: accentColor,
anonymous: false,
hideIf: (serverConfigs) => !serverConfigs?.REFERRAL_ENABLED,
},
{
label: "titles.referral.short",
icon: LoveCuteFiIcon,
onPress: ({ navigation }) => {
navigation.pushControllerView(ReferralScreen)
},
iconBackgroundColor: "#EC4899",
anonymous: false,
hideIf: (serverConfigs) => !serverConfigs?.REFERRAL_ENABLED,
},
]
const DataGroupNavigationLinks: GroupNavigationLink[] = [
{
label: "titles.actions",
@ -210,7 +237,7 @@ const NavigationLinkGroup: FC<{
</GroupedInsetListNavigationLinkIcon>
}
onPress={() => {
if (link.trialNotAllowed && role === UserRole.Trial) {
if (link.trialNotAllowed && (role === UserRole.Free || role === UserRole.Trial)) {
navigation.presentControllerView(InvitationScreen)
} else {
link.onPress({ navigation })
@ -226,6 +253,7 @@ const NavigationLinkGroup: FC<{
const navigationGroups = [
SettingGroupNavigationLinks,
DataGroupNavigationLinks,
ReferralGroupNavigationLinks,
BetaGroupNavigationLinks,
PrivacyGroupNavigationLinks,
ActionGroupNavigationLinks,

View File

@ -1,5 +1,6 @@
import { UserRole, UserRoleName } from "@follow/constants"
import { useImageColors } from "@follow/store/image/hooks"
import { useUserById } from "@follow/store/user/hooks"
import { useUserById, useUserRole } from "@follow/store/user/hooks"
import { cn, getLuminance } from "@follow/utils"
import { LinearGradient } from "expo-linear-gradient"
import type { FC } from "react"
@ -10,18 +11,21 @@ import ReAnimated, { FadeIn, FadeOut, interpolate, useAnimatedStyle } from "reac
import { useSafeAreaInsets } from "react-native-safe-area-context"
import { useColor } from "react-native-uikit-colors"
import { useServerConfigs } from "@/src/atoms/server-configs"
import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar"
import { DiscordCuteFiIcon } from "@/src/icons/discord_cute_fi"
import { FacebookCuteFiIcon } from "@/src/icons/facebook_cute_fi"
import { GithubCuteFiIcon } from "@/src/icons/github_cute_fi"
import { InstagramCuteFiIcon } from "@/src/icons/instagram_cute_fi"
import { LinkCuteReIcon } from "@/src/icons/link_cute_re"
import { PowerIcon } from "@/src/icons/power"
import { TwitterCuteFiIcon } from "@/src/icons/twitter_cute_fi"
import { WebCuteReIcon } from "@/src/icons/web_cute_re"
import { YoutubeCuteFiIcon } from "@/src/icons/youtube_cute_fi"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { LoginScreen } from "@/src/screens/(modal)/LoginScreen"
import { usePrefetchImageColors } from "@/src/store/image/hooks"
import { accentColor } from "@/src/theme/colors"
const defaultGradientColors = ["#000", "#100", "#200"]
@ -40,14 +44,19 @@ const PlatformInfoMap: Record<
export const UserHeaderBanner = ({
scrollY,
userId,
showRoleBadge,
}: {
scrollY: SharedValue<number>
userId?: string
showRoleBadge?: boolean
}) => {
const serverConfigs = useServerConfigs()
const bgColor = useColor("systemGroupedBackground")
const avatarIconColor = useColor("secondaryLabel")
const user = useUserById(userId)
const role = useUserRole()
usePrefetchImageColors(user?.image)
const insets = useSafeAreaInsets()
@ -146,13 +155,11 @@ export const UserHeaderBanner = ({
)}
</ReAnimated.View>
<View className="items-center px-4 pb-[24px]" style={{ paddingTop: insets.top }}>
<ReAnimated.View
style={avatarStyles}
className="bg-system-background overflow-hidden rounded-full"
>
<ReAnimated.View style={avatarStyles} className="bg-system-background rounded-full">
<UserAvatar
image={user?.image}
name={user?.name}
role={showRoleBadge && serverConfigs?.REFERRAL_ENABLED ? role : undefined}
size={60}
className={!user?.name ? "bg-system-grouped-background" : ""}
color={avatarIconColor}
@ -174,6 +181,34 @@ export const UserHeaderBanner = ({
<Text className="text-text text-2xl font-bold">Folo Account</Text>
)}
{!!role && serverConfigs?.REFERRAL_ENABLED && (
<View className="my-1 flex flex-row items-center gap-2">
<PowerIcon
color={
role === UserRole.Trial || role === UserRole.Free
? gradientLight
? "rgba(0,0,0,0.7)"
: "rgba(255,255,255,0.7)"
: accentColor
}
width={16}
height={16}
/>
<Text
className={cn(
role === UserRole.Trial || role === UserRole.Free
? gradientLight
? "text-black/70"
: "text-white/70"
: "text-accent",
"font-semibold",
)}
>
{UserRoleName[role]}
</Text>
</View>
)}
{user?.handle ? (
<Text className={cn(gradientLight ? "text-black/70" : "text-white/70")}>
@{user.handle}

View File

@ -0,0 +1,307 @@
import { UserRole, UserRoleName } from "@follow/constants"
import { useRoleEndAt, useUserRole } from "@follow/store/user/hooks"
import { cn } from "@follow/utils"
import { useQuery } from "@tanstack/react-query"
import dayjs from "dayjs"
import type { ProductPurchase } from "expo-iap"
import { useIAP } from "expo-iap"
import { openURL } from "expo-linking"
import { useEffect } from "react"
import { Trans, useTranslation } from "react-i18next"
import { Linking, Pressable, ScrollView, Text, View } from "react-native"
import { useServerConfigs } from "@/src/atoms/server-configs"
import {
NavigationBlurEffectHeaderView,
SafeNavigationScrollView,
} from "@/src/components/layouts/views/SafeNavigationScrollView"
import {
GroupedInformationCell,
GroupedInsetListCard,
} from "@/src/components/ui/grouped/GroupedList"
import { CheckLineIcon } from "@/src/icons/check_line"
import { PowerOutlineIcon } from "@/src/icons/power_outline"
import { TimeCuteReIcon } from "@/src/icons/time_cute_re"
import { apiClient } from "@/src/lib/api-fetch"
import { authClient } from "@/src/lib/auth"
import { useNavigation } from "@/src/lib/navigation/hooks"
import type { NavigationControllerView } from "@/src/lib/navigation/types"
import { isIOS } from "@/src/lib/platform"
import { proxyEnv } from "@/src/lib/proxy-env"
import { accentColor } from "@/src/theme/colors"
import { ReferralScreen } from "./Referral"
// Plan configuration types
interface Plan {
id: string
title: string
price: string
period: string
features: string[]
isPopular?: boolean
role: UserRole
isComingSoon?: boolean
tier: number // Add tier for hierarchy comparison
}
// Plan hierarchy: Free (1) < Pro Preview (2) < Pro (3)
const PLAN_TIER_MAP: Record<UserRole, number> = {
[UserRole.Admin]: 4, // Admin has highest tier
[UserRole.Free]: 1,
[UserRole.Trial]: 1, // Same as Free (deprecated)
[UserRole.PreProTrial]: 2, // Same tier as PrePro
[UserRole.PrePro]: 2,
[UserRole.Pro]: 3,
}
// Plan configurations
const PLAN_CONFIGS: Plan[] = [
{
id: "free",
title: UserRoleName[UserRole.Free],
price: "$0",
period: "",
features: ["50 feeds", "10 lists"],
isPopular: false,
role: UserRole.Free,
tier: PLAN_TIER_MAP[UserRole.Free],
},
{
id: "pro-preview",
title: UserRoleName[UserRole.PrePro],
price: "$1 or 3 invitations",
period: "",
features: ["1000 feeds and lists", "10 inboxes", "10 actions", "100 webhooks"],
isPopular: false,
role: UserRole.PrePro,
tier: PLAN_TIER_MAP[UserRole.PrePro],
},
{
id: "pro",
title: UserRoleName[UserRole.Pro],
price: "Coming soon",
period: "",
features: [`Everything in ${UserRoleName[UserRole.PrePro]}`, "Advanced AI features"],
isPopular: false,
role: UserRole.Pro,
isComingSoon: true,
tier: PLAN_TIER_MAP[UserRole.Pro],
},
]
const useReferralInfoQuery = () => {
return useQuery({
queryKey: ["referral", "info"],
queryFn: () => apiClient.referrals.$get().then((res) => res.data),
})
}
export const PlanScreen: NavigationControllerView = () => {
const { connected, getProducts, requestPurchase, validateReceipt } = useIAP({
onPurchaseSuccess: (purchase) => {
validatePurchase(purchase)
},
onPurchaseError: (error) => {
console.error("Purchase failed:", error)
},
})
useEffect(() => {
if (connected) {
getProducts(["is.follow.propreview"])
}
}, [connected])
const validatePurchase = async (purchase: ProductPurchase) => {
if (!purchase.transactionId) {
return
}
try {
const result = await validateReceipt(purchase.transactionId)
if (result.isValid) {
apiClient.referrals["verify-receipt"].$post({ json: { appReceipt: result.receiptData } })
}
} catch (error) {
console.error("Validation failed:", error)
}
}
const navigation = useNavigation()
const { t } = useTranslation("settings")
const serverConfigs = useServerConfigs()
const ruleLink = serverConfigs?.REFERRAL_RULE_LINK
const requiredInvitationsAmount = serverConfigs?.REFERRAL_REQUIRED_INVITATIONS || 3
const { data: referralInfo } = useReferralInfoQuery()
const validInvitationsAmount = referralInfo?.invitations.filter((i) => i.usedAt).length || 0
const role = useUserRole()
const roleEndDate = useRoleEndAt()
const daysLeft = roleEndDate
? Math.ceil((roleEndDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24))
: null
const progress = (validInvitationsAmount / requiredInvitationsAmount) * 100
return (
<SafeNavigationScrollView
className="bg-system-grouped-background"
Header={<NavigationBlurEffectHeaderView title={t("titles.plan.long")} />}
>
<View className="mt-6">
<GroupedInsetListCard>
<GroupedInformationCell
title={t("titles.plan.short")}
icon={<PowerOutlineIcon height={40} width={40} color="#fff" />}
iconBackgroundColor={accentColor}
>
<Trans
ns="settings"
i18nKey="plan.description"
parent={({ children }: { children: React.ReactNode }) => (
<Text className="text-label mt-3 text-left text-base leading-tight">
{children}
</Text>
)}
components={{
Link: (
<Text
className="text-accent"
onPress={() => {
if (ruleLink) {
Linking.openURL(ruleLink)
}
}}
>
Learn more
</Text>
),
}}
/>
</GroupedInformationCell>
</GroupedInsetListCard>
</View>
<ScrollView horizontal className="m-4 py-4">
{PLAN_CONFIGS.map((plan) => (
<PlanCard
key={plan.id}
plan={plan}
currentUserRole={role || null}
daysLeft={daysLeft}
isCurrentPlan={
role === plan.role || (plan.role === UserRole.PrePro && role === UserRole.PreProTrial)
}
/>
))}
</ScrollView>
<View className="bg-secondary-system-grouped-background mx-4 rounded-lg p-4">
<View className="mb-4 flex-row items-center gap-2">
<View className="bg-accent rounded-full p-2">
<TimeCuteReIcon color="#fff" width={16} height={16} />
</View>
<View>
<Text className="text-label font-medium">Current Status</Text>
<Text className="text-label text-sm">
{role === UserRole.PrePro
? "You have an active Pro Preview plan"
: role === UserRole.PreProTrial
? `Pro Preview trial expires ${dayjs(roleEndDate).format("MMMM D, YYYY")} (${daysLeft} days left)`
: "Start your journey with our referral program"}
</Text>
</View>
</View>
<View className="mb-4 flex-row items-center justify-between">
<Text className="text-label font-medium">Referral Progress</Text>
<Text className="text-label">
{validInvitationsAmount} / {requiredInvitationsAmount}
</Text>
</View>
<View className="bg-label h-2 w-full rounded-full">
<View
className="bg-accent h-2 rounded-full"
style={{
width: `${progress}%`,
}}
/>
</View>
{role !== UserRole.PrePro && (
<View className="mt-4 flex-row items-center gap-2 self-end">
<Pressable
className="bg-accent rounded-lg p-2"
onPress={() => {
navigation.pushControllerView(ReferralScreen)
}}
>
<Text className="text-white">{`Invite ${serverConfigs?.REFERRAL_REQUIRED_INVITATIONS || 3} friends`}</Text>
</Pressable>
<Text className="text-label">or</Text>
<Pressable
className="bg-accent rounded-lg p-2"
onPress={async () => {
if (isIOS) {
requestPurchase({ request: { sku: "is.follow.propreview" } })
} else {
const res = await authClient.subscription.upgrade({
plan: "folo pro preview",
successUrl: proxyEnv.WEB_URL,
cancelUrl: proxyEnv.WEB_URL,
disableRedirect: true,
})
if (res.data?.url) {
openURL(res.data.url)
}
}
}}
>
<Text className="text-white">{`Pay $${serverConfigs?.REFERRAL_PRO_PREVIEW_STRIPE_PRICE_IN_DOLLAR || 1}`}</Text>
</Pressable>
</View>
)}
</View>
</SafeNavigationScrollView>
)
}
interface PlanCardProps {
plan: Plan
currentUserRole: UserRole | null
isCurrentPlan: boolean
daysLeft: number | null
}
function PlanCard({ plan, isCurrentPlan, daysLeft }: PlanCardProps) {
return (
<View
className={cn(
"bg-secondary-system-grouped-background mr-4 min-w-[160px] rounded-lg p-4 shadow-md",
isCurrentPlan && "border-accent border-2",
plan.isComingSoon && "opacity-75",
)}
>
<Text className="text-label text-lg font-bold">{plan.title}</Text>
<Text className="text-label mb-4 text-lg font-bold">{plan.price}</Text>
{plan.features.map((feature, index) => (
<View key={index} className="mb-2 flex-row items-center gap-2">
<View className="bg-green/10 rounded-full p-[2px]">
<CheckLineIcon width={16} height={16} color="rgb(40, 205, 65)" />
</View>
<Text className="text-label text-sm">{feature}</Text>
</View>
))}
{plan.isComingSoon ? (
<Text className="text-label border-opaque-separator mt-2 rounded-lg border p-2 text-center text-sm text-gray-500">
Coming soon
</Text>
) : isCurrentPlan && daysLeft !== null ? (
<Text className="text-label border-opaque-separator mt-2 rounded-lg border p-2 text-center text-sm text-gray-500">
{`In Trial (${daysLeft} days left)`}
</Text>
) : null}
</View>
)
}

View File

@ -0,0 +1,177 @@
import { UserRole, UserRoleName } from "@follow/constants"
import { env } from "@follow/shared/env.rn"
import { useWhoami } from "@follow/store/user/hooks"
import { cn } from "@follow/utils"
import { useQuery } from "@tanstack/react-query"
import dayjs from "dayjs"
import { setStringAsync } from "expo-clipboard"
import { Trans, useTranslation } from "react-i18next"
import { Linking, Pressable, Share, Text, View } from "react-native"
import { useServerConfigs } from "@/src/atoms/server-configs"
import {
NavigationBlurEffectHeaderView,
SafeNavigationScrollView,
} from "@/src/components/layouts/views/SafeNavigationScrollView"
import { UserAvatar } from "@/src/components/ui/avatar/UserAvatar"
import { ContextMenu } from "@/src/components/ui/context-menu"
import {
GroupedInformationCell,
GroupedInsetActivityIndicatorCell,
GroupedInsetListBaseCell,
GroupedInsetListCard,
GroupedInsetListSectionHeader,
} from "@/src/components/ui/grouped/GroupedList"
import { MonoText } from "@/src/components/ui/typography/MonoText"
import { LoveCuteFiIcon } from "@/src/icons/love_cute_fi"
import { apiClient } from "@/src/lib/api-fetch"
import type { NavigationControllerView } from "@/src/lib/navigation/types"
import { toast } from "@/src/lib/toast"
import { useColor } from "@/src/theme/colors"
const useReferralInfoQuery = () => {
return useQuery({
queryKey: ["referral", "info"],
queryFn: () => apiClient.referrals.$get().then((res) => res.data),
})
}
export const ReferralScreen: NavigationControllerView = () => {
const { t } = useTranslation("settings")
const serverConfigs = useServerConfigs()
const ruleLink = serverConfigs?.REFERRAL_RULE_LINK
const requiredInvitationsAmount = serverConfigs?.REFERRAL_REQUIRED_INVITATIONS || 3
const { data: referralInfo, isLoading } = useReferralInfoQuery()
const invitations = referralInfo?.invitations
const validInvitationsAmount = referralInfo?.invitations.filter((i) => i.usedAt).length || 0
const user = useWhoami()
const referralLink = `${env.WEB_URL}/register?referral=${user?.handle || user?.id}`
const secondaryLabelColor = useColor("secondaryLabel")
const progress = (validInvitationsAmount / requiredInvitationsAmount) * 100
return (
<SafeNavigationScrollView
className="bg-system-grouped-background"
Header={<NavigationBlurEffectHeaderView title={t("titles.referral.long")} />}
>
<View className="mt-6">
<GroupedInsetListCard>
<GroupedInformationCell
title={t("titles.referral.short")}
icon={<LoveCuteFiIcon height={40} width={40} color="#fff" />}
iconBackgroundColor={"#EC4899"}
>
<Trans
ns="settings"
i18nKey="referral.description"
values={{
day: referralInfo?.referralCycleDays || 45,
}}
parent={({ children }: { children: React.ReactNode }) => (
<Text className="text-label mt-3 text-left text-base leading-tight">
{children}
</Text>
)}
components={{
Link: (
<Text
className="text-accent"
onPress={() => {
if (ruleLink) {
Linking.openURL(ruleLink)
}
}}
>
Learn more
</Text>
),
}}
/>
</GroupedInformationCell>
</GroupedInsetListCard>
</View>
<GroupedInsetListSectionHeader label={t("referral.link")} />
<GroupedInsetListCard>
<ContextMenu.Root>
<ContextMenu.Trigger>
<GroupedInsetListBaseCell>
<Pressable
onPress={() => {
Share.share({ url: referralLink })
}}
>
<MonoText className="text-label">{referralLink}</MonoText>
</Pressable>
</GroupedInsetListBaseCell>
</ContextMenu.Trigger>
<ContextMenu.Content>
<ContextMenu.Item
key="copy"
onSelect={() => {
setStringAsync(referralLink)
toast.success("Referral link copied to clipboard")
}}
>
<ContextMenu.ItemTitle>Copy</ContextMenu.ItemTitle>
</ContextMenu.Item>
</ContextMenu.Content>
</ContextMenu.Root>
</GroupedInsetListCard>
<GroupedInsetListSectionHeader
label={`Referral Progress for the Free ${UserRoleName[UserRole.PrePro]} ${validInvitationsAmount}/${requiredInvitationsAmount}:`}
/>
<GroupedInsetListCard>
<GroupedInsetListBaseCell>
<View className="bg-label h-2 w-full rounded-full">
<View
className="bg-accent h-2 rounded-full"
style={{
width: `${progress}%`,
}}
/>
</View>
</GroupedInsetListBaseCell>
</GroupedInsetListCard>
<GroupedInsetListSectionHeader label={"Your Invited Friends"} />
<GroupedInsetListCard>
{isLoading && <GroupedInsetActivityIndicatorCell />}
{invitations?.map((invitation) => (
<GroupedInsetListBaseCell
key={invitation.code}
className="bg-secondary-system-grouped-background flex-1"
>
<View className="mr-2 shrink flex-row items-center gap-4">
<UserAvatar
size={26}
image={invitation.user?.image}
preview={false}
color={secondaryLabelColor}
/>
<View className="min-w-0 shrink">
<Text
className={cn("text-label", !invitation.user && "text-secondary-label")}
numberOfLines={1}
>
{invitation.user?.name || (!invitation.user ? t("invitation.notUsed") : "")}
</Text>
<Text className="text-secondary-label text-sm">
{t("invitation.created_at")} {dayjs(invitation.createdAt).format("YYYY/MM/DD")}
</Text>
</View>
</View>
<Text className="text-label">
{invitation.usedAt
? t("referral.invited_friend_status.valid")
: t("referral.invited_friend_status.pending")}
</Text>
</GroupedInsetListBaseCell>
))}
</GroupedInsetListCard>
</SafeNavigationScrollView>
)
}

View File

@ -34,7 +34,11 @@ export function Settings() {
className="bg-system-grouped-background flex-1"
contentViewClassName="-mt-24 pb-8"
>
<UserHeaderBanner scrollY={screenContext.reAnimatedScrollY} userId={whoami?.id} />
<UserHeaderBanner
scrollY={screenContext.reAnimatedScrollY}
userId={whoami?.id}
showRoleBadge
/>
<SettingsList />
</SafeNavigationScrollView>

View File

@ -0,0 +1,7 @@
import type { ServerConfigs } from "@follow/models/types"
import { createAtomHooks } from "@follow/utils/jotai"
import { atom } from "jotai"
export const [, , useServerConfigs, , getServerConfigs, setServerConfigs] = createAtomHooks(
atom<Nullable<ServerConfigs>>(null),
)

View File

@ -1,5 +1,6 @@
import { useWhoami } from "@client/atoms/user"
import { Avatar, AvatarFallback, AvatarImage } from "@follow/components/ui/avatar/index.jsx"
import { UserRole } from "@follow/constants"
import { getBackgroundGradient } from "@follow/utils/color"
import { cn } from "@follow/utils/utils"
import { useMemo } from "react"
@ -26,13 +27,15 @@ export const UserAvatar = ({ className }: { className?: string }) => {
name: "Innei",
image: "https://avatars-githubusercontent-webp.webp.se/u/41265413?v=4",
handle: "innei",
role: UserRole.Free,
roleEndAt: new Date(),
deleted: false,
}
} else {
return null
}
}
const { name, image } = user
const { name, image } = user!
return (
<div

View File

@ -0,0 +1,101 @@
// sync this file with apps/desktop/layer/renderer/src/modules/auth/ReferralForm.tsx
import { apiClient } from "@client/lib/api-fetch"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@follow/components/ui/form/index.jsx"
import { Input } from "@follow/components/ui/input/Input.jsx"
import { getStorageNS } from "@follow/utils/ns"
import { cn } from "@follow/utils/utils"
import { zodResolver } from "@hookform/resolvers/zod"
import { useQuery } from "@tanstack/react-query"
import { useEffect } from "react"
import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
const formSchema = z.object({
referral: z.string().optional(),
})
function getDefaultReferralCode() {
const urlParams = new URLSearchParams(window.location.search)
const referralCodeFromUrl = urlParams.get("referral")
if (referralCodeFromUrl) {
localStorage.setItem(getStorageNS("referral-code"), referralCodeFromUrl)
}
const referralCodeFromLocalStorage = localStorage.getItem(getStorageNS("referral-code"))
return referralCodeFromUrl || referralCodeFromLocalStorage || ""
}
async function getReferralCycleDays(code: string) {
return apiClient.referrals.days.$get({ query: { code } })
}
export function ReferralForm({ className }: { className?: string }) {
const { t } = useTranslation()
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
referral: getDefaultReferralCode(),
},
})
const { watch } = form
useEffect(() => {
const sub = watch((value) => {
const referralCode = value.referral
if (referralCode) {
localStorage.setItem(getStorageNS("referral-code"), referralCode)
}
})
return () => sub.unsubscribe()
}, [watch])
const referral = watch("referral")
const { data } = useQuery({
queryKey: ["referral", "days", referral],
queryFn: () => getReferralCycleDays(referral || ""),
enabled: !!referral,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
})
const days = data?.data.referralCycleDays || 0
return (
<Form {...form}>
<form className={cn(className)}>
<FormField
control={form.control}
name="referral"
render={({ field }) => (
<FormItem>
<FormLabel>{t("register.referral.label")}</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormDescription>
{days || !referral
? days
? t("register.referral.days", {
days,
})
: t("register.referral.description")
: t("register.referral.invalid")}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)
}

View File

@ -1,4 +1,6 @@
import { useServerConfigs } from "@client/atoms/server-configs"
import { loginHandler, signUp } from "@client/lib/auth"
import { ReferralForm } from "@client/modules/referral"
import { useAuthProviders } from "@client/query/users"
import { Logo } from "@follow/components/icons/logo.jsx"
import { Button, MotionButtonBase } from "@follow/components/ui/button/index.jsx"
@ -46,6 +48,7 @@ const formSchema = z
})
function RegisterForm() {
const serverConfigs = useServerConfigs()
const { t } = useTranslation()
const [isSubmitting, setIsSubmitting] = useState(false)
const navigate = useNavigate()
@ -185,6 +188,7 @@ function RegisterForm() {
</div>
)}
<Divider className="my-7" />
{serverConfigs?.REFERRAL_ENABLED && <ReferralForm className="mb-4" />}
{isEmail ? (
<div className="cursor-pointer pb-2 text-center" onClick={() => setIsEmail(false)}>
Back

View File

@ -10,6 +10,7 @@ import type { FC, PropsWithChildren } from "react"
import { queryClient } from "../lib/query-client"
import { jotaiStore } from "../lib/store"
import { ServerConfigsProvider } from "./server-configs-provider"
import { UserProvider } from "./user-provider"
const ThemeProvider = () => {
@ -21,6 +22,7 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
<MotionProvider>
<Provider store={jotaiStore}>
<QueryClientProvider client={queryClient}>
<ServerConfigsProvider />
<ThemeProvider />
<EventProvider />
<StableRouterProvider />

View File

@ -0,0 +1,23 @@
import { setServerConfigs } from "@client/atoms/server-configs"
import { apiClient } from "@client/lib/api-fetch"
import { useQuery } from "@tanstack/react-query"
import { useEffect } from "react"
const useServerConfigsQuery = () => {
const { data } = useQuery({
queryKey: ["server-configs"],
queryFn: async () => await apiClient.status.configs.$get(),
})
return data?.data
}
export const ServerConfigsProvider = () => {
const serverConfigs = useServerConfigsQuery()
useEffect(() => {
if (!serverConfigs) return
setServerConfigs(serverConfigs)
}, [serverConfigs])
return null
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M20.239 5.214c-.1.027-.397.172-.66.323-3.769 2.156-6.552 4.587-8.958 7.823-.418.562-1.212 1.739-1.441 2.134l-.112.194-.104-.134c-.536-.691-1.843-2.06-2.644-2.771-.871-.774-2.093-1.717-2.42-1.867-.338-.156-.845-.051-1.108.229a1.013 1.013 0 0 0-.103 1.242c.05.075.388.358.751.63.808.605 1.134.876 1.88 1.565.963.89 1.785 1.809 2.665 2.978.272.363.556.701.629.751a.976.976 0 0 0 .966.074c.274-.13.365-.239.731-.885 1.674-2.95 3.645-5.33 6.149-7.424 1.116-.933 2.733-2.037 4.097-2.796.527-.294.696-.429.809-.651.385-.756-.316-1.636-1.127-1.415" fill="#10161F" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 687 B

View File

@ -322,6 +322,10 @@
"register.email": "Email",
"register.login": "Login",
"register.password": "Password",
"register.referral.days": "Sign up with this referral code to get {{days}} days of Pro Preview",
"register.referral.description": "Sign up with referral code to get extra days of Pro Preview",
"register.referral.invalid": "Invalid referral code",
"register.referral.label": "Referral Code",
"register.submit": "Create account",
"resize.tooltip.double_click_to_collapse": "<b>Double click</b> to reset to default size",
"resize.tooltip.drag_to_resize": "<b>Drag</b> to resize",

View File

@ -67,5 +67,6 @@
"13007": "This RSSHub instance is unavailable currently",
"14000": "Invalid file",
"14001": "File too large",
"14002": "Upload failed"
"14002": "Upload failed",
"15000": "AI token limit exceeded. Please try again later."
}

View File

@ -78,6 +78,10 @@
"register.label": "Sign up to {{app_name}}",
"register.login": "Sign in",
"register.password": "Password",
"register.referral.days": "Sign up with this referral code to get {{days}} days of Pro Preview",
"register.referral.description": "Sign up with referral code to get extra days of Pro Preview",
"register.referral.invalid": "Invalid referral code",
"register.referral.label": "Referral Code",
"register.submit": "Create account",
"words.email": "Email"
}

View File

@ -397,6 +397,7 @@
"notifications.test": "Test Notification",
"notifications.test_success": "Test notification sent successfully.",
"notifications.token": "Client Token",
"plan.description": "Enjoy every Pro feature from day one—new users get a fullaccess trial, and you can keep those perks simply by inviting friends or making a small support contribution. <Link>Learn more</Link>.",
"privacy.privacy": "Privacy",
"privacy.terms": "Terms",
"profile.avatar.cropInstructions": "Drag the crop area to adjust your avatar",
@ -470,6 +471,13 @@
"profile.two_factor.no_password": "You need to <Link>set</Link> a password before enabling 2FA.",
"profile.updateSuccess": "Profile updated.",
"profile.update_password_success": "Password updated.",
"referral.description": "Share Folo with your friends! For every friend who signs up and activates their account, your Pro Preview will be extended. Friends you invite will also get {{day}} days of Pro Preview! <Link>Learn more</Link>.",
"referral.invited_friend_status.pending": "Pending validation",
"referral.invited_friend_status.valid": "Valid",
"referral.link": "Your Invite Link:",
"referral.pro_status.preview": "Your Pro Preview Status: Expires {{dateString}} ({{daysLeft}} days left)",
"referral.pro_status.trial": "Your Current Tier: Free",
"referral.pro_status.user": "Your Pro Preview Status: Active",
"rsshub.addModal.access_key_label": "Access Key (Optional)",
"rsshub.addModal.add": "Add",
"rsshub.addModal.base_url_label": "Base URL",
@ -511,8 +519,12 @@
"titles.invitations": "Invitations",
"titles.lists": "Lists",
"titles.notifications": "Notifications",
"titles.plan.long": "Upgrade your plan",
"titles.plan.short": "Plan",
"titles.power": "Power",
"titles.privacy": "Privacy",
"titles.referral.long": "Invite Friends & Extend Pro",
"titles.referral.short": "Invite & Earn",
"titles.shortcuts": "Shortcuts",
"titles.sign_out": "Sign Out",
"wallet.balance.activePoints": "Active Points",

View File

@ -38,6 +38,7 @@
"@radix-ui/react-label": "2.1.7",
"@radix-ui/react-navigation-menu": "1.2.13",
"@radix-ui/react-popover": "1.1.14",
"@radix-ui/react-progress": "1.1.7",
"@radix-ui/react-radio-group": "1.3.7",
"@radix-ui/react-scroll-area": "1.2.9",
"@radix-ui/react-select": "2.2.5",

View File

@ -28,6 +28,7 @@ const motionBaseMap = {
export const MotionButtonBase = ({
ref,
children,
disabled,
...rest
}: HTMLMotionProps<"button"> & { ref?: React.Ref<HTMLButtonElement | null> }) => {
const isMobile = useMobile()
@ -35,7 +36,8 @@ export const MotionButtonBase = ({
<m.button
layout="size"
initial
{...motionBaseMap[isMobile ? "mobile" : "pc"]}
disabled={disabled}
{...(disabled ? {} : motionBaseMap[isMobile ? "mobile" : "pc"])}
{...rest}
ref={ref}
>

View File

@ -0,0 +1,28 @@
"use client"
import { cn } from "@follow/utils/utils"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import * as React from "react"
const Progress = ({
ref,
className,
value,
...props
}: React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
ref?: React.RefObject<React.ElementRef<typeof ProgressPrimitive.Root> | null>
}) => (
<ProgressPrimitive.Root
ref={ref}
className={cn("bg-accent/20 relative h-2 w-full overflow-hidden rounded-full", className)}
{...props}
>
<ProgressPrimitive.Indicator
className="bg-accent size-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -13,6 +13,28 @@ export enum Routes {
}
export enum UserRole {
Admin = "admin",
PreProTrial = "pre_pro_trial",
PrePro = "pre_pro",
Free = "free",
/**
* @deprecated
* @see UserRole.Free
*/
// TODO: remove this
Trial = "trial",
User = "user",
Pro = "pro",
}
export const UserRoleName: Record<UserRole, string> = {
[UserRole.Admin]: "Admin",
[UserRole.PreProTrial]: "Pro Preview Trial",
[UserRole.PrePro]: "Pro Preview",
[UserRole.Free]: "Free",
/**
* @deprecated
* @see UserRole.Free
*/
[UserRole.Trial]: "Free",
[UserRole.Pro]: "Pro",
} as const

View File

@ -16,6 +16,7 @@ export type UserModel = OptionalKey<
| "bio"
| "website"
| "socialLinks"
| "stripeCustomerId"
>
export type ExtractBizResponse<T extends (...args: any[]) => any> = Exclude<

View File

@ -31,6 +31,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@better-auth/stripe": "1.2.9",
"@electron-toolkit/preload": "3.0.2",
"@electron-toolkit/tsconfig": "1.0.1",
"@hono/node-server": "1.15.0",
@ -40,6 +41,7 @@
"drizzle-orm": "0.44.2",
"hono": "4.8.1",
"sonner": "2.0.6",
"stripe": "18.2.1",
"zod": "3.25.75"
}
}

View File

@ -1,3 +1,4 @@
import { stripeClient } from "@better-auth/stripe/client"
import { IN_ELECTRON } from "@follow/shared"
import type { authPlugins } from "@follow/shared/hono"
import type { BetterAuthClientPlugin, BetterFetchOption } from "better-auth/client"
@ -31,6 +32,7 @@ export const baseAuthPlugins = [
},
}),
twoFactorClient(),
stripeClient({ subscription: true }),
] satisfies BetterAuthClientPlugin[]
export type AuthClient<ExtraPlugins extends BetterAuthClientPlugin[] = []> = ReturnType<
@ -54,7 +56,19 @@ export class Auth {
this.authClient = createAuthClient({
baseURL: `${this.options.apiURL}/better-auth`,
plugins: baseAuthPlugins,
fetchOptions: this.options.fetchOptions,
fetchOptions: {
...this.options.fetchOptions,
onRequest: (context) => {
const referralCode = localStorage.getItem(getStorageNS("referral-code"))
if (referralCode) {
context.headers.set("folo-referral-code", referralCode)
}
this.options.fetchOptions?.onRequest?.(context)
return context
},
},
})
}
@ -86,3 +100,7 @@ export class Auth {
}
}
}
// copy from packages/internal/utils/src/ns.ts
const ns = "follow"
const getStorageNS = (key: string) => `${ns}:${key}`

File diff suppressed because it is too large Load Diff

View File

@ -42,6 +42,10 @@ export const useUserRole = () => {
return useUserStore((state) => state.role)
}
export const useRoleEndAt = () => {
return useUserStore((state) => state.roleEndAt)
}
export const useUserById = (userId: string | undefined) => {
return useUserStore((state) => (userId ? state.users[userId] : undefined))
}

View File

@ -20,12 +20,14 @@ type UserStore = {
users: Record<string, UserModel>
whoami: MeModel | null
role: UserRole | null
roleEndAt: Date | null
}
const defaultState: UserStore = {
users: {},
whoami: null,
role: null,
roleEndAt: null,
}
export const useUserStore = createZustandStore<UserStore>("user")(() => defaultState)
@ -71,7 +73,10 @@ class UserSyncService {
const user = honoMorph.toUser(res.user, true)
immerSet((state) => {
state.whoami = { ...user, emailVerified: res.user.emailVerified }
state.role = res.role as UserRole
state.role = res.role
if (res.roleEndAt) {
state.roleEndAt = new Date(res.roleEndAt)
}
})
userActions.upsertMany([user])
@ -176,7 +181,7 @@ class UserSyncService {
const res = await apiClient().invitations.use.$post({ json: { code } })
if (res.code === 0) {
immerSet((state) => {
state.role = UserRole.User
state.role = UserRole.PrePro
})
}
@ -249,6 +254,7 @@ class UserActions implements Hydratable, Resetable {
immerSet((state) => {
state.whoami = null
state.role = null
state.roleEndAt = null
})
})
tx.persist(() => UserService.removeCurrentUser())

View File

@ -883,6 +883,9 @@ importers:
expo-haptics:
specifier: 14.1.4
version: 14.1.4(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))
expo-iap:
specifier: 2.5.0
version: 2.5.0(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0)
expo-image:
specifier: 2.3.0
version: 2.3.0(patch_hash=2b9c83844cf8c4240bda8315b7bbe20908929272eca362f1b7f52728071ae446)(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native-web@0.20.0(encoding@0.1.13)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0)
@ -1422,6 +1425,9 @@ importers:
'@radix-ui/react-popover':
specifier: 1.1.14
version: 1.1.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-progress':
specifier: 1.1.7
version: 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-radio-group':
specifier: 1.3.7
version: 1.3.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -1664,6 +1670,9 @@ importers:
packages/internal/shared:
dependencies:
'@better-auth/stripe':
specifier: 1.2.9
version: 1.2.9
'@electron-toolkit/preload':
specifier: 3.0.2
version: 3.0.2(electron@37.2.0)
@ -1691,6 +1700,9 @@ importers:
sonner:
specifier: 2.0.6
version: 2.0.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
stripe:
specifier: 18.2.1
version: 18.2.1(@types/node@24.0.10)
zod:
specifier: 3.25.75
version: 3.25.75
@ -2656,6 +2668,9 @@ packages:
peerDependencies:
better-auth: 1.2.9
'@better-auth/stripe@1.2.9':
resolution: {integrity: sha512-p7Q3rX63UBE+KMlRTGHeytp13/g8fQU5w8wcY6FPubsGEAPoZl7ymcqKu3AAxpFTaWOGHPVov+4no7uNGl6Qug==}
'@better-auth/utils@0.2.5':
resolution: {integrity: sha512-uI2+/8h/zVsH8RrYdG8eUErbuGBk16rZKQfz8CjxQOyCE6v7BqFYEbFwvOkvl1KbUdxhqOnXp78+uE5h8qVEgQ==}
@ -5077,6 +5092,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-progress@1.1.7':
resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: 19.0.0
react-dom: 19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-radio-group@1.3.7':
resolution: {integrity: sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g==}
peerDependencies:
@ -9261,6 +9289,13 @@ packages:
peerDependencies:
expo: '*'
expo-iap@2.5.0:
resolution: {integrity: sha512-z9VBIM8/eGoJmkKoVu/Bnr39K8Tx8LMKC9ZNdfjMaGEdtgt5Vt5LBqdzCqb3efJ5nxkUDz7dhn9Rj+Robwts4g==}
peerDependencies:
expo: '*'
react: 19.0.0
react-native: '*'
expo-image-loader@5.1.0:
resolution: {integrity: sha512-sEBx3zDQIODWbB5JwzE7ZL5FJD+DK3LVLWBVJy6VzsqIA6nDEnSFnsnWyCfCTSvbGigMATs1lgkC2nz3Jpve1Q==}
peerDependencies:
@ -14115,6 +14150,15 @@ packages:
resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==}
engines: {node: '>=0.10.0'}
stripe@18.2.1:
resolution: {integrity: sha512-GwB1B7WSwEBzW4dilgyJruUYhbGMscrwuyHsPUmSRKrGHZ5poSh2oU9XKdii5BFVJzXHn35geRvGJ6R8bYcp8w==}
engines: {node: '>=12.*'}
peerDependencies:
'@types/node': '>=12.x.x'
peerDependenciesMeta:
'@types/node':
optional: true
strtok3@6.3.0:
resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==}
engines: {node: '>=10'}
@ -16451,6 +16495,11 @@ snapshots:
better-call: 1.0.9
zod: 3.25.75
'@better-auth/stripe@1.2.9':
dependencies:
better-auth: 1.2.9
zod: 3.25.75
'@better-auth/utils@0.2.5':
dependencies:
typescript: 5.8.3
@ -19942,6 +19991,16 @@ snapshots:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-progress@1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/react-context': 1.1.2(@types/react@19.1.8)(react@19.0.0)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.1.8
'@types/react-dom': 19.1.6(@types/react@19.1.8)
'@radix-ui/react-radio-group@1.3.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
@ -24928,6 +24987,12 @@ snapshots:
dependencies:
expo: 53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0)
expo-iap@2.5.0(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0):
dependencies:
expo: 53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0)
react: 19.0.0
react-native: 0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)
expo-image-loader@5.1.0(expo@53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0)):
dependencies:
expo: 53.0.12(@babel/core@7.28.0)(@expo/metro-runtime@5.0.4(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0)))(bufferutil@4.0.9)(graphql@16.8.1)(react-native-webview@13.15.0(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0))(react-native@0.79.1(@babel/core@7.28.0)(@types/react@19.1.8)(bufferutil@4.0.9)(react@19.0.0))(react@19.0.0)
@ -30600,6 +30665,12 @@ snapshots:
dependencies:
escape-string-regexp: 1.0.5
stripe@18.2.1(@types/node@24.0.10):
dependencies:
qs: 6.14.0
optionalDependencies:
'@types/node': 24.0.10
strtok3@6.3.0:
dependencies:
'@tokenizer/token': 0.3.0