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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions app/api/location/currency/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'

const FALLBACK_CURRENCY = 'USD'

export async function GET() {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 2500)

try {
const response = await fetch('https://ipapi.co/json/', {
cache: 'no-store',
headers: {
Accept: 'application/json',
'User-Agent': 'tera-pricing/1.0',
},
signal: controller.signal,
})

if (!response.ok) {
return NextResponse.json({ currency: FALLBACK_CURRENCY, countryCode: '' })
}

const data = await response.json()

return NextResponse.json({
currency: typeof data?.currency === 'string' ? data.currency : FALLBACK_CURRENCY,
countryCode: typeof data?.country_code === 'string' ? data.country_code : '',
})
} catch {
return NextResponse.json({ currency: FALLBACK_CURRENCY, countryCode: '' })
} finally {
clearTimeout(timeout)
}
}
7 changes: 6 additions & 1 deletion app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import AppLayout from '@/components/AppLayout'
import { Analytics } from '@vercel/analytics/react'
import { ThemeProvider } from '@/components/ThemeProvider'

const shouldRenderAnalytics =
process.env.VERCEL === '1' ||
process.env.VERCEL === 'true' ||
Boolean(process.env.VERCEL_URL)

export const viewport = {
width: 'device-width',
initialScale: 1,
Expand Down Expand Up @@ -39,7 +44,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<ThemeProvider>
<AppLayout>
{children}
<Analytics />
{shouldRenderAnalytics ? <Analytics /> : null}
</AppLayout>
</ThemeProvider>
</AuthProvider>
Expand Down
13 changes: 7 additions & 6 deletions app/pricing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,14 @@ export default function PricingPage() {
useEffect(() => {
const loadUserAndCurrency = async () => {
try {
const response = await fetch('https://ipapi.co/json/')
const data = await response.json()
const currencyCode = data.currency || 'USD'
setCurrency(CURRENCY_CODES[currencyCode] || CURRENCY_CODES.USD)
setCountryCode(data.country_code || '')
const response = await fetch('/api/location/currency', { cache: 'no-store' })
if (response.ok) {
const data = await response.json()
const currencyCode = data.currency || 'USD'
setCurrency(CURRENCY_CODES[currencyCode] || CURRENCY_CODES.USD)
setCountryCode(data.countryCode || '')
}
} catch (error) {
console.error('Error fetching currency:', error)
setCurrency(CURRENCY_CODES.USD)
}

Expand Down
117 changes: 83 additions & 34 deletions lib/free-plan-credits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const PLAN_MONTHLY_CREDIT_CAPS: Record<PlanType, number> = {
plus: 5000
}
const RESET_INTERVAL_DAYS = 30
const RESET_INTERVAL_MS = RESET_INTERVAL_DAYS * 24 * 60 * 60 * 1000

type CreditState = {
used: number
Expand All @@ -16,6 +17,12 @@ type CreditState = {
plan: PlanType
}

type UserCreditRecord = {
plan: PlanType
used: number
resetDate: Date | null
}

export function getFreePlanCreditCap() {
return PLAN_MONTHLY_CREDIT_CAPS.free
}
Expand All @@ -24,61 +31,103 @@ export function getPlanCreditCap(plan: PlanType): number {
return PLAN_MONTHLY_CREDIT_CAPS[plan]
}

export async function getUserCreditsRemaining(userId: string): Promise<CreditState> {
const nextResetFromNow = () => {
const date = new Date()
date.setDate(date.getDate() + RESET_INTERVAL_DAYS)
return date.toISOString()
function getNextResetDate(from: Date = new Date()) {
const date = new Date(from)
date.setDate(date.getDate() + RESET_INTERVAL_DAYS)
return date
}

function normalizePlan(plan: string | null | undefined): PlanType {
if (plan === 'pro' || plan === 'plus') {
return plan
}

try {
const { data, error } = await supabaseServer
.from('users')
.select('subscription_plan, free_plan_credits_used, free_plan_credits_reset_date')
.eq('id', userId)
.single()
return 'free'
}

if (error || !data) {
return { used: 0, remaining: PLAN_MONTHLY_CREDIT_CAPS.free, total: PLAN_MONTHLY_CREDIT_CAPS.free, resetDate: nextResetFromNow(), plan: 'free' }
}
async function getUserCreditRecord(userId: string): Promise<UserCreditRecord | null> {
const { data, error } = await supabaseServer
.from('users')
.select('subscription_plan, free_plan_credits_used, free_plan_credits_reset_date')
.eq('id', userId)
.maybeSingle()

const plan = (data.subscription_plan || 'free') as PlanType
const total = getPlanCreditCap(plan)
if (error || !data) {
return null
}

return {
plan: normalizePlan(data.subscription_plan),
used: Math.max(0, Number(data.free_plan_credits_used || 0)),
resetDate: data.free_plan_credits_reset_date ? new Date(data.free_plan_credits_reset_date) : null,
}
}

async function getUserPlan(userId: string): Promise<PlanType> {
const { data, error } = await supabaseServer
.from('users')
.select('subscription_plan')
.eq('id', userId)
.maybeSingle()

if (error || !data) {
return 'free'
}

return normalizePlan(data.subscription_plan)
}

async function getSessionCreditUsage(userId: string, windowStart: Date): Promise<number> {
const { data, error } = await supabaseServer
.from('chat_sessions')
.select('token_usage')
.eq('user_id', userId)
.gte('created_at', windowStart.toISOString())

if (error || !data) {
return 0
}

return data.reduce((sum: number, row: { token_usage?: number | null }) => sum + Math.max(0, Number(row.token_usage || 0)), 0)
}

export async function getUserCreditsRemaining(userId: string): Promise<CreditState> {
try {
const now = new Date()
const resetDate = data.free_plan_credits_reset_date ? new Date(data.free_plan_credits_reset_date) : null
if (!resetDate || now > resetDate) {
return { used: 0, remaining: total, total, resetDate: nextResetFromNow(), plan }
}
const record = await getUserCreditRecord(userId)
const plan = record?.plan ?? await getUserPlan(userId)
const total = getPlanCreditCap(plan)

const used = data.free_plan_credits_used || 0
const activeResetDate = record?.resetDate && now <= record.resetDate
? record.resetDate
: getNextResetDate(now)
const windowStart = new Date(activeResetDate.getTime() - RESET_INTERVAL_MS)
const sessionUsage = await getSessionCreditUsage(userId, windowStart)
const storedUsage = record?.resetDate && now <= record.resetDate ? record.used : 0
const used = Math.max(storedUsage, sessionUsage)
const remaining = Math.max(0, total - used)
return { used, remaining, total, resetDate: resetDate.toISOString(), plan }
return { used, remaining, total, resetDate: activeResetDate.toISOString(), plan }
} catch (error) {
return { used: 0, remaining: PLAN_MONTHLY_CREDIT_CAPS.free, total: PLAN_MONTHLY_CREDIT_CAPS.free, resetDate: nextResetFromNow(), plan: 'free' }
const resetDate = getNextResetDate().toISOString()
return { used: 0, remaining: PLAN_MONTHLY_CREDIT_CAPS.free, total: PLAN_MONTHLY_CREDIT_CAPS.free, resetDate, plan: 'free' }
}
}

export async function incrementUserCredits(userId: string, cost: number): Promise<boolean> {
try {
const { data, error } = await supabaseServer
.from('users')
.select('subscription_plan, free_plan_credits_used, free_plan_credits_reset_date')
.eq('id', userId)
.single()
const record = await getUserCreditRecord(userId)

if (error || !data) return false
if (!record) return true

const now = new Date()
const resetDate = data.free_plan_credits_reset_date ? new Date(data.free_plan_credits_reset_date) : null
const resetDate = record.resetDate

let used = data.free_plan_credits_used || 0
let used = record.used
const updatePayload: { free_plan_credits_used: number; free_plan_credits_reset_date?: string } = { free_plan_credits_used: used }

if (!resetDate || now > resetDate) {
used = 0
const nextReset = new Date()
nextReset.setDate(nextReset.getDate() + RESET_INTERVAL_DAYS)
const nextReset = getNextResetDate(now)
updatePayload.free_plan_credits_reset_date = nextReset.toISOString()
}

Expand All @@ -91,6 +140,6 @@ export async function incrementUserCredits(userId: string, cost: number): Promis

return !updateError
} catch (error) {
return false
return true
}
}