feat: referral (#3761)
Co-authored-by: DIYgod <i@diygod.me> Co-authored-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
79c9b05246
commit
30bb37c7b9
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export const {
|
|||
signIn,
|
||||
signOut,
|
||||
signUp,
|
||||
subscription,
|
||||
twoFactor,
|
||||
unlinkAccount,
|
||||
updateUser,
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 */,
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@
|
|||
ReferencedContainer = "container:Folo.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
<StoreKitConfigurationFileReference
|
||||
identifier = "../Folo - Follow everything.storekit">
|
||||
</StoreKitConfigurationFileReference>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 full‑access 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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export type UserModel = OptionalKey<
|
|||
| "bio"
|
||||
| "website"
|
||||
| "socialLinks"
|
||||
| "stripeCustomerId"
|
||||
>
|
||||
|
||||
export type ExtractBizResponse<T extends (...args: any[]) => any> = Exclude<
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue