Resolve every sibling package through node_modules

The project files inherited the root paths map, so a package's typecheck
read its siblings' source instead of their published declarations and
failed on files that were never its own. An ESM-only manifest now names
no dist/cjs path under any condition, the native emit names its renamed
map, and the runner has a test of its own again.
This commit is contained in:
2026-09-03 18:02:50 -07:00
parent 3011889b77
commit de489abd1e
456 changed files with 522 additions and 7140 deletions
+2
View File
@@ -86,3 +86,5 @@ test-results/
# Rust build artifacts — never commit; a debug .a is 272MB and GitHub caps at 100MB.
apps/desktop/src-tauri/target/
**/src-tauri/target/
apps/demos/types/**/*.d.ts
apps/demos/types/**/*.d.ts.map
+15 -15
View File
@@ -11,36 +11,36 @@
"sideEffects": [
"*.css"
],
"main": "dist/cjs",
"module": "dist/esm",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "./src",
"exports": {
"./package.json": "./package.json",
"./photo/*": "./public/*.jpg",
".": {
"types": "./src/index.tsx",
"react-native": "./dist/esm/index.native.js",
"browser": "./dist/esm/index.mjs",
"module": "./dist/esm/index.mjs",
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs",
"default": "./dist/esm/index.mjs"
"react-native": "./dist/esm/index.js",
"browser": "./dist/esm/index.js",
"module": "./dist/esm/index.js",
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js",
"default": "./dist/esm/index.js"
},
"./demo/*": {
"types": "./types/*.d.ts",
"react-native": "./dist/esm/*.native.js",
"browser": "./dist/esm/*.mjs",
"module": "./dist/esm/*.mjs",
"import": "./dist/esm/*.mjs",
"require": "./dist/cjs/*.cjs",
"default": "./dist/esm/*.mjs"
"react-native": "./dist/esm/*.js",
"browser": "./dist/esm/*.js",
"module": "./dist/esm/*.js",
"import": "./dist/esm/*.js",
"require": "./dist/cjs/*.js",
"default": "./dist/esm/*.js"
}
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "hanzogui-build --skip-types",
"build": "hanzogui-build --skip-types --skip-native",
"watch": "hanzogui-build --skip-types --watch",
"clean": "hanzogui-build clean",
"clean:build": "hanzogui-build clean:build"
@@ -8,6 +8,7 @@
"rewriteRelativeImportExtensions": true,
"sourceMap": true,
"inlineSourceMap": false,
"paths": {},
"module": "commonjs",
"moduleResolution": "bundler",
"outDir": "dist/cjs",
@@ -8,6 +8,7 @@
"rewriteRelativeImportExtensions": true,
"sourceMap": true,
"inlineSourceMap": false,
"paths": {},
"module": "esnext",
"moduleResolution": "bundler",
"outDir": "dist/esm",
@@ -1,278 +0,0 @@
import { ChevronRight } from '@hanzogui/lucide-icons-2'
import { memo, useEffect, useState } from 'react'
import {
Button,
Paragraph,
ScrollView,
Separator,
Spacer,
TooltipSimple,
XGroup,
XStack,
} from '@hanzo/gui'
import { Link } from '~/components/Link'
import { useThemeBuilderStore } from '~/features/studio/theme/store/ThemeBuilderStore'
import type { ThemeSuiteItemData } from '~/features/studio/theme/types'
import { ContainerLarge } from './Containers'
const HomeIcon = ({ size = 18, color = 'currentColor' }) => (
<svg width={size} height={size} viewBox="0 0 256 256" fill={color}>
<path d="M224,115.55V208a16,16,0,0,1-16,16H168a16,16,0,0,1-16-16V168a8,8,0,0,0-8-8H112a8,8,0,0,0-8,8v40a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V115.55a16,16,0,0,1,5.17-11.78l80-75.48a16,16,0,0,1,21.66,0l80,75.48A16,16,0,0,1,224,115.55Z" />
</svg>
)
const bannerEnabled = true
const bannerHeight = 42
export const useBannerHeight = () => {
return bannerEnabled ? bannerHeight : 0
}
type ThemeWithData = {
id: number
name: string
slug: string
search_query: string
theme_data: ThemeSuiteItemData | null
}
// featured theme ids with display names
const featuredThemesMeta = [
{ id: 1980, name: 'B/W', slug: 'black-and-white-hi-contrast' },
{ id: 1951, name: 'Ocean', slug: 'ocean-blue-theme' },
{ id: 82, name: 'SUPER', slug: 'supreme' },
{ id: 53, name: 'Cactus', slug: 'desert' },
{ id: 37, name: 'Neon', slug: 'nike-neon' },
] as const
// initialize with static data so buttons render on SSR
const initialFeaturedThemes: ThemeWithData[] = featuredThemesMeta.map((meta) => ({
...meta,
search_query: meta.name,
theme_data: null,
}))
export const PromoBanner = () => {
if (!bannerEnabled) {
return null
}
return (
<XStack
bg="$background02"
width="100%"
items="center"
justify="center"
z={10001}
position="relative"
>
<ContainerLarge px={0} flexDirection="row">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
alignItems: 'center',
justify: 'center',
minW: '100%',
}}
>
<XStack flex={1} items="center" gap="$4" px="$4" py="$2">
<XStack items="center" gap="$4">
<Link href="/">
<Button chromeless size="$2" circular icon={HomeIcon} scaleIcon={1.5} />
</Link>
<TooltipSimple label="Gui 2 is here! It's a massive new release »">
<Link href="/blog/version-two">
<Paragraph
size="$2"
color="$color11"
hoverStyle={{ color: '$color12' }}
cursor="pointer"
ellipsis
>
Version 2 is here 🎉
</Paragraph>
</Link>
</TooltipSimple>
<Separator vertical height={16} borderColor="$color5" />
<TooltipSimple label="Ever wished there was a Rails for cross-platform React?">
<Link href="/takeout">
<Paragraph
size="$2"
color="$color10"
hoverStyle={{ color: '$color11' }}
cursor="pointer"
ellipsis
>
Takeout 2 is here, too!
</Paragraph>
</Link>
</TooltipSimple>
{/* <Separator vertical height={16} borderColor="$color5" /> */}
</XStack>
<Spacer flex={1} />
<BannerThemes />
</XStack>
</ScrollView>
</ContainerLarge>
</XStack>
)
}
const BannerThemes = memo(() => {
const [featuredThemes, setFeaturedThemes] =
useState<ThemeWithData[]>(initialFeaturedThemes)
// const [recentThemes, setRecentThemes] = useState<ThemeWithData[]>([])
const { updateGenerate, clearTheme, currentThemeId } = useThemeBuilderStore()
useEffect(() => {
fetch('/api/theme/recent')
.then((res) => res.json())
.then((data) => {
// if (data.recent) {
// setRecentThemes(
// data.recent.slice(0, 3).map((t: any) => ({
// id: t.id,
// name: t.search_query,
// slug: '',
// search_query: t.search_query,
// theme_data: t.theme_data,
// }))
// )
// }
if (data.featured) {
const featuredWithMeta = featuredThemesMeta.map((meta) => {
const apiTheme = data.featured.find((t: any) => t.id === meta.id)
return {
...meta,
search_query: apiTheme?.search_query || meta.name,
theme_data: apiTheme?.theme_data || null,
}
})
setFeaturedThemes(featuredWithMeta)
}
})
.catch(console.error)
}, [])
const handleThemeClick = (theme: ThemeWithData) => {
setTimeout(() => {
// toggle off if already active
if (currentThemeId === String(theme.id)) {
clearTheme()
return
}
if (theme.theme_data) {
updateGenerate(theme.theme_data, theme.search_query, theme.id)
}
})
}
return (
<>
<XStack items="center" gap="$4">
<TooltipSimple label="To celebrate v2, here's some free themes!">
<Paragraph size="$2" select="none" color="$color9">
Themes:
</Paragraph>
</TooltipSimple>
<XStack my={-3} items="center" gap="$2">
{featuredThemes.map((theme) => {
const isActive = currentThemeId === String(theme.id)
return (
<XGroup key={theme.id} size="$2" rounded="$10">
<XGroup.Item>
<Button
// theme={isActive ? 'white' : null}
bg="transparent"
hoverStyle={{
bg: '$background02',
}}
size="$2.5"
rounded="$10"
borderWidth={0}
onPress={() => handleThemeClick(theme)}
>
<Paragraph
size="$2"
fontWeight="500"
fontFamily="$mono"
color={isActive ? '$color12' : '$color9'}
>
{theme.name}
</Paragraph>
</Button>
</XGroup.Item>
<Link href={`/theme/${theme.id}/${theme.slug}`}>
<XGroup.Item>
<Button
size="$2.5"
bg="transparent"
hoverStyle={{
bg: '$background02',
}}
rounded="$10"
borderWidth={0}
icon={<ChevronRight mx={-3} ml={-6} size={12} opacity={0.15} />}
scaleIcon={0.8}
/>
</XGroup.Item>
</Link>
</XGroup>
)
})}
</XStack>
</XStack>
{/* <AnimatePresence>
{recentThemes.length > 0 && (
<XStack key="recent-themes" items="center" gap="$2">
<Separator vertical height={16} bg="$color5" />
<Paragraph size="$1" color="$color8">
Recently:
</Paragraph>
{recentThemes.map((theme) => {
const isActive = currentThemeId === String(theme.id)
return (
<Theme key={`recent-${theme.id}`} name={isActive ? 'green' : null}>
<Button
size="$2"
rounded="$10"
borderWidth={0}
onPress={() => handleThemeClick(theme)}
{...(isActive && { bg: '$color5' })}
hoverStyle={{ bg: '$color4' }}
pressStyle={{ bg: '$color5' }}
>
<Paragraph
ellipsis
display="inline-flex"
overflow="hidden"
whiteSpace="nowrap"
size="$2"
maxW={80}
fontFamily="$mono"
fontWeight="500"
>
{theme.search_query}
</Paragraph>
</Button>
</Theme>
)
})}
</XStack>
)}
</AnimatePresence> */}
</>
)
})
@@ -1,37 +0,0 @@
import { getAccessToken } from '~/features/auth/useSupabaseClient'
/**
* authenticated fetch helper for client-side API calls
*
* IMPORTANT: all API calls that require authentication must use this helper
* or manually include the Authorization header. cookies alone are not reliable
* for auth in production due to cross-origin/SameSite issues.
*
* the server-side ensureAuth function checks:
* 1. Authorization header (Bearer token) - preferred
* 2. cookies - fallback, but unreliable in some environments
*
* usage:
* const response = await authFetch('/api/create-subscription', {
* method: 'POST',
* body: JSON.stringify({ ... }),
* })
*/
export async function authFetch(
url: string,
options: RequestInit = {}
): Promise<Response> {
const accessToken = await getAccessToken()
const headers = new Headers(options.headers)
headers.set('Content-Type', 'application/json')
if (accessToken) {
headers.set('Authorization', `Bearer ${accessToken}`)
}
return fetch(url, {
...options,
headers,
})
}
-55
View File
@@ -1,55 +0,0 @@
type CookieOptions = {
key: string
value: string
path?: string
domain?: string
expiration?: number // in seconds
secure?: boolean
httpOnly?: boolean
sameSite?: 'strict' | 'lax' | 'none' | 'Strict' | 'Lax' | 'None' | boolean
}
export function createCookie(options: CookieOptions) {
let cookie = `${options.key}=${encodeURIComponent(options.value)}`
if (options.expiration) {
const date = new Date()
date.setTime(date.getTime() + options.expiration * 1000)
cookie += `; Expires=${date.toUTCString()}`
}
if (options.path) {
cookie += `; Path=${options.path}`
}
if (options.domain) {
cookie += `; Domain=${options.domain}`
}
if (options.secure) {
cookie += '; Secure'
}
if (options.httpOnly) {
cookie += '; HttpOnly'
}
if (options.sameSite) {
cookie += `; SameSite=${options.sameSite === true ? 'strict' : options.sameSite}`
}
return cookie
}
export function setCookie(headers: Headers, options: CookieOptions) {
headers.append('Set-Cookie', createCookie(options))
}
export function removeCookie(headers: Headers, key: string) {
headers.delete(key)
}
export function getCookie(headers: Headers, key: string): string | null {
const cookies = headers.getSetCookie()
if (!cookies) return null
for (const cookie of cookies) {
const [cookieKey, cookieValue] = cookie.split('=')
if (cookieKey === key) {
return decodeURIComponent(cookieValue)
}
}
return null
}
@@ -1,7 +0,0 @@
export const generateModels = {
'claude-sonnet-4-6': true,
} as const
export type ModelNames = keyof typeof generateModels
export const defaultModel: ModelNames = 'claude-sonnet-4-6'
@@ -1,9 +0,0 @@
export const getQuery = (req: Request): Record<string, string | string[]> => {
const searchParams = new URL(req.url).searchParams
const out = {}
searchParams.forEach((value, key) => {
const all = searchParams.getAll(key)
out[key] = all.length > 1 ? all : value
})
return out
}
@@ -1,25 +0,0 @@
export async function readBodyBuffer(req: Request) {
if (!req.body) {
return null
}
const reader = req.body.getReader()
const chunks: any[] = []
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
}
const totalLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0)
const uint8Array = new Uint8Array(totalLength)
let offset = 0
for (const chunk of chunks) {
uint8Array.set(chunk, offset)
offset += chunk.length
}
return Buffer.from(uint8Array)
}
@@ -1,22 +0,0 @@
export async function readBodyJSON(req: Request) {
if (req.body) {
const bodyString = await streamToString(req.body)
return JSON.parse(bodyString)
}
return null
}
async function streamToString(stream: ReadableStream<Uint8Array> | null) {
if (!stream) {
return ''
}
const reader = stream.getReader()
let result = ''
let read = await reader.read()
while (!read.done) {
result += new TextDecoder('utf-8').decode(read.value)
read = await reader.read()
}
reader.releaseLock()
return result
}
@@ -1,10 +0,0 @@
// read a server-only secret at runtime.
//
// the production server build (`one build`) inlines `process.env.X` references
// as literal strings into dist/api when X is set at build time -- which bakes
// live secrets (service_role key, sk_live, the GitHub PAT, ...) into the
// deployed artifact at rest. the dynamic property access here can't be matched
// by that static `process.env.X` replacement, so the value stays a real runtime
// env lookup and never lands in the bundle. always read server secrets through
// this, never `process.env.SECRET` directly.
export const serverEnv = (key: string): string | undefined => process.env[key]
@@ -1,617 +0,0 @@
import { createClient } from '@supabase/supabase-js'
import type Stripe from 'stripe'
import { serverEnv } from '../api/serverEnv'
import { sendProductPurchaseEmail } from '~/features/email/helpers'
import { stripe } from '~/features/stripe/stripe'
import type { Price, Product } from '~/features/stripe/types'
import { ProductSlug } from '~/shared/types/subscription'
import { STRIPE_PRODUCTS } from '../stripe/products'
import type { Database } from '../supabase/types'
const SUPA_URL = import.meta.env.NEXT_PUBLIC_SUPABASE_URL || 'http://localhost:54321'
const SUPA_KEY = serverEnv('SUPABASE_SERVICE_ROLE_KEY') || ''
// Note: supabaseAdmin uses the SERVICE_ROLE_KEY which you must only use in a secure server-side context
// as it has admin priviliges and overwrites RLS policies!
// lazy init to avoid errors in dev mode when key isn't set
let _supabaseAdmin: ReturnType<typeof createClient<Database>> | null = null
export const supabaseAdmin = new Proxy({} as ReturnType<typeof createClient<Database>>, {
get(_, prop) {
if (!_supabaseAdmin) {
if (!SUPA_KEY) {
if (process.env.NODE_ENV === 'development') {
console.warn(`supabaseAdmin: no SUPABASE_SERVICE_ROLE_KEY set, skipping in dev`)
// return a no-op proxy for dev
return () =>
Promise.resolve({ data: null, error: new Error('No supabase key in dev') })
}
throw new Error('supabaseKey is required')
}
console.info(`Connecting to supabase: ${SUPA_URL}`)
_supabaseAdmin = createClient<Database>(SUPA_URL, SUPA_KEY)
}
return (_supabaseAdmin as any)[prop]
},
})
const toDateTime = (secs: number) => new Date(secs * 1000)
export const getBentoCode = async (codePath: string) => {
const { data, error } = await supabaseAdmin.storage
.from('bento')
.download(`merged/${codePath}.tsx`)
if (error) {
console.error(error)
throw new Error(`Error getting bento code for ${codePath}`)
}
return data.text()
}
export const getBentoComponentCategory = async ({
categoryPath,
categorySectionPath,
fileName,
}: {
categoryPath: string
categorySectionPath?: string
fileName?: string
}) => {
let rootPath = `${categoryPath}`
if (categorySectionPath) {
rootPath += `/${categorySectionPath}`
}
const downloadPath = `unmerged/${rootPath}`
const { data: fileList, error } = await supabaseAdmin.storage
.from('bento')
.list(downloadPath)
if (error) {
throw new Error(`Error getting bento code for ${categoryPath} ${categorySectionPath}`)
}
let result: { [key: string]: Array<{ path: string; downloadUrl: string }> } = {}
const processFolder = async (folderPath: string, folderName: string) => {
const { data: subFileList, error: subError } = await supabaseAdmin.storage
.from('bento')
.list(folderPath)
if (subError) {
throw new Error(`Error getting bento code for ${folderPath}`)
}
const subFiles = await Promise.all(
subFileList.map(async (subFile) => {
const { data: signedUrl, error: signError } = await supabaseAdmin.storage
.from('bento')
.createSignedUrl(`${folderPath}/${subFile.name}`, 60)
if (signError) {
throw new Error(`Error creating signed URL for ${subFile.name}`)
}
return {
path: `${folderName}/${subFile.name}`,
downloadUrl: signedUrl.signedUrl,
}
})
)
return subFiles.filter(
(file): file is { path: string; downloadUrl: string } => file !== null
)
}
for (const item of fileList) {
if (item.id === null) {
// This is a folder, process its contents
const folderFiles = await processFolder(
`${downloadPath}/${item.name}`,
`${rootPath}/${item.name}`
)
if (folderFiles.length > 0) {
result[`${rootPath}/${item.name}`] = folderFiles
}
} else if (item.name.includes(fileName || '')) {
// This is a file in the main folder
const { data: signedUrl, error: signError } = await supabaseAdmin.storage
.from('bento')
.createSignedUrl(`${downloadPath}/${item.name}`, 60)
if (signError) {
throw new Error(`Error creating signed URL for ${item.name}`)
}
result[rootPath] = result[rootPath] || []
result[rootPath].push({
path: item.name,
downloadUrl: signedUrl.signedUrl,
})
}
}
return result
}
export const upsertProductRecord = async (product: Stripe.Product) => {
const productData: Product = {
id: product.id,
active: product.active,
name: product.name,
description: product.description ?? undefined,
image: product.images?.[0] ?? null,
metadata: product.metadata,
}
const { error } = await supabaseAdmin.from('products').upsert([productData])
if (error) throw error
console.info(`Product inserted/updated: ${product.id}`)
}
export const deleteProductRecord = async (id: Stripe.Product['id']) => {
const { error } = await supabaseAdmin.from('products').delete().eq('id', id)
if (error) throw error
console.info(`Product deleted: ${id}`)
}
export const upsertPriceRecord = async (price: Stripe.Price) => {
const priceData: Price = {
id: price.id,
product_id: typeof price.product === 'string' ? price.product : price.product.id,
active: price.active,
currency: price.currency,
description: price.nickname ?? undefined,
type: price.type,
unit_amount: price.unit_amount!,
interval: price.recurring?.interval,
interval_count: price.recurring?.interval_count,
trial_period_days: price.recurring?.trial_period_days,
metadata: price.metadata,
}
// @ts-expect-error
const { error } = await supabaseAdmin.from('prices').upsert([priceData])
if (error) throw error
console.info(`Price inserted/updated: ${price.id}`)
// we call this to create associated subscription prices upfront - instead of creating it on the fly when someone purchases a one-time price.
await getOrCreateRenewalPriceId(price)
}
export const deletePriceRecord = async (id: Stripe.Price['id']) => {
const { error } = await supabaseAdmin.from('prices').delete().eq('id', id)
if (error) throw error
console.info(`Price deleted: ${id}`)
}
export const createOrRetrieveCustomer = async ({
email,
uuid,
}: {
email: string
uuid: string
}) => {
const { data, error } = await supabaseAdmin
.from('customers')
.select('stripe_customer_id')
.eq('id', uuid)
.single()
if (error) {
// No customer record found, let's create one.
const customerData: { metadata: { supabaseUUID: string }; email?: string } = {
metadata: {
supabaseUUID: uuid,
},
}
if (email) customerData.email = email
const customer = await stripe.customers.create(customerData)
// Now insert the customer ID into our Supabase mapping table.
const { error: supabaseError } = await supabaseAdmin
.from('customers')
.insert([{ id: uuid, stripe_customer_id: customer.id }])
if (supabaseError) throw supabaseError
console.info(`createOrRetrieveCustomer ${uuid}`)
return customer.id
}
if (data) return data.stripe_customer_id
}
/**
* Copies the billing details from the payment method to the customer object.
*/
const copyBillingDetailsToCustomer = async (
uuid: string,
payment_method: Stripe.PaymentMethod
) => {
//Todo: check this assertion
const customer = payment_method.customer as string
const { name, phone, address } = payment_method.billing_details
if (!name || !phone || !address) return
//@ts-ignore
await stripe.customers.update(customer, { name, phone, address })
const { error } = await supabaseAdmin
.from('users')
.update({
// billing_address: address,
// payment_method: payment_method[payment_method.type],
})
.eq('id', uuid)
if (error) throw error
}
export const manageSubscriptionStatusChange = async (
subscriptionId: string,
customerId: string,
createAction = false
) => {
// Get customer's UUID from mapping table.
const { data: customerData, error: noCustomerError } = await supabaseAdmin
.from('customers')
.select('id')
.eq('stripe_customer_id', customerId)
.single()
if (noCustomerError) throw noCustomerError
const { id: uuid } = customerData || {}
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ['default_payment_method'],
})
console.info(`Insert new subscriptions`)
const { error } = await supabaseAdmin.from('subscriptions').upsert([
{
id: subscription.id,
user_id: uuid,
metadata: subscription.metadata,
status: subscription.status,
//TODO check quantity on subscription
// @ts-ignore
quantity: subscription.quantity,
cancel_at_period_end: subscription.cancel_at_period_end,
cancel_at: subscription.cancel_at
? (toDateTime(subscription.cancel_at) as unknown as string)
: null,
canceled_at: subscription.canceled_at
? (toDateTime(subscription.canceled_at) as unknown as string)
: null,
current_period_start: toDateTime(
subscription.current_period_start
) as unknown as string,
current_period_end: toDateTime(
subscription.current_period_end
) as unknown as string,
created: toDateTime(subscription.created) as unknown as string,
ended_at: subscription.ended_at
? (toDateTime(subscription.ended_at) as unknown as string)
: null,
trial_start: subscription.trial_start
? (toDateTime(subscription.trial_start) as unknown as string)
: null,
trial_end: subscription.trial_end
? (toDateTime(subscription.trial_end) as unknown as string)
: null,
},
])
if (error) {
throw error
}
// instead of deleting right away, we collect them to delete after the insert below
// because we have a contsraint on app_installations and i dont want to mess that up
console.info(`Find old subscription_items`)
const { error: findSubsErr, data: oldSubscriptionItems } = await supabaseAdmin
.from('subscription_items')
.select('*')
.eq('subscription_id', subscription.id)
if (findSubsErr) {
throw findSubsErr
}
console.info(`Insert new subscription_items`)
const { error: insertionError } = await supabaseAdmin.from('subscription_items').upsert(
subscription.items.data.map((item) => ({
id: item.id,
subscription_id: subscription.id,
price_id: typeof item.price === 'string' ? item.price : item.price.id,
}))
)
if (insertionError) {
throw insertionError
}
// then delete the old ones since we inserted new ones so shouldn't cause contraint err:
if (oldSubscriptionItems?.length) {
const oldRecords = oldSubscriptionItems
.map((x) => x.id)
.filter((id) => !subscription.items.data.some((x) => x.id === id))
console.info(`Delete old subscription_items`, oldRecords)
const { error: deleteOldErr } = await supabaseAdmin
.from('subscription_items')
.delete()
.in(
'id',
// delete ones that aren't leftover
oldRecords
)
if (deleteOldErr) {
throw deleteOldErr
}
}
console.info(`Inserted/updated subscription [${subscription.id}] for user [${uuid}]`)
// For a new subscription copy the billing details to the customer object.
// NOTE: This is a costly operation and should happen at the very end.
if (createAction && subscription.default_payment_method && uuid) {
//@ts-ignore
await copyBillingDetailsToCustomer(
uuid,
subscription.default_payment_method as Stripe.PaymentMethod
)
}
if (createAction) {
const user = await supabaseAdmin.auth.admin.getUserById(customerData.id)
if (user.error) {
throw user.error
}
const email = user.data.user.email
if (!email) {
throw new Error(`No email found for user ${user.data.user.id}`)
}
const userModel = await supabaseAdmin
.from('users')
.select('id, full_name')
.eq('id', user.data.user.id)
.single()
if (userModel.error) {
throw userModel.error
}
const subscribedProducts = await Promise.all(
subscription.items.data.map((item) =>
stripe.products.retrieve(item.price.product as string)
)
)
const userName = userModel.data.full_name ?? email.split('@').shift()!
const includesTakeoutStarter = subscribedProducts.some(
(product) => product.metadata.slug === ProductSlug.UniversalStarter
)
const includesBento = subscribedProducts.some(
(product) => product.metadata.slug === 'bento'
)
// Send one consolidated purchase email
if (includesTakeoutStarter || includesBento) {
const productNames: string[] = []
if (includesTakeoutStarter) productNames.push('Takeout')
if (includesBento) productNames.push('Bento')
await sendProductPurchaseEmail(email, {
name: userName,
product_name: productNames.join(' + '),
})
console.info(`Purchase email sent to ${email} for: ${productNames.join(', ')}`)
}
}
}
export const createTeamSubscription = async (sub: Stripe.Subscription) => {
const teamItem = sub.items.data.find(
(item) => item.price.id === STRIPE_PRODUCTS.PRO_TEAM_SEATS.priceId
)
// if there is no team item, return
if (!teamItem) return
const subscriptionId = sub.id
const { data: subscriptionData, error: subscriptionError } = await supabaseAdmin
.from('subscriptions')
.select('*')
.eq('id', subscriptionId)
.single()
if (subscriptionError) throw subscriptionError
const userId = subscriptionData.user_id
const { data: teamSubscriptionData, error: teamSubscriptionError } = await supabaseAdmin
.from('team_subscriptions')
.insert({
owner_id: userId,
total_seats: teamItem.quantity || 1,
expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
})
if (teamSubscriptionError) throw teamSubscriptionError
}
export const createTeamInvoice = async (sub: Stripe.Invoice) => {
const teamItem = sub.lines.data.find(
(d) => d.price?.id === STRIPE_PRODUCTS.PRO_TEAM_SEATS_ONE_TIME.priceId
)
if (!teamItem) return
const { data: subscriptionData, error: subscriptionError } = await supabaseAdmin
.from('subscriptions')
.select('*')
.eq('id', sub.id)
.single()
if (subscriptionError) throw subscriptionError
const { data: teamSubscriptionData, error: teamSubscriptionError } = await supabaseAdmin
.from('team_subscriptions')
.insert({
owner_id: subscriptionData.user_id,
total_seats: teamItem.quantity || 1,
expires_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(),
})
if (teamSubscriptionError) throw teamSubscriptionError
}
export async function deleteSubscriptionRecord(sub: Stripe.Subscription) {
const { error } = await supabaseAdmin.from('subscriptions').delete().eq('id', sub.id)
if (error) throw error
console.error(`Deleted subscription: ${sub.id}`)
}
export async function addRenewalSubscription(
sessionFromEvent: Stripe.Checkout.Session,
options?: { toltReferral?: string }
) {
const session = await stripe.checkout.sessions.retrieve(sessionFromEvent.id, {
expand: ['line_items'],
})
const prices = await Promise.all(
session.line_items!.data.map((lineItem) =>
stripe.prices.retrieve(lineItem.price!.id, {
expand: ['product'],
})
)
)
const customerId =
typeof session.customer === 'string' ? session.customer : session.customer!.id
const { data: userData, error: userError } = await supabaseAdmin
.from('customers')
.select('*')
.eq('stripe_customer_id', customerId)
.single()
if (userError) {
throw userError
}
const userId = userData.id
const renewalPriceIds: string[] = []
for (const price of prices) {
if (price.metadata.is_lifetime) {
// create ownership record for the user if it's for lifetime
await supabaseAdmin.from('product_ownership').insert({
price_id: price.id,
user_id: userId,
})
continue
}
// else, we need to:
// 1. get or create a recurring price
// 2. set up a subscription to the price with 1 year of trial so that it starts charging after 1y
// we add the 1y trial because the user just paid for the first year
if (typeof price.product === 'string' || price.product.deleted) {
console.warn('no product object - returning')
continue
}
const renewalPriceId = await getOrCreateRenewalPriceId(price)
if (!renewalPriceId) {
console.warn('no has_renewals metadata found - returning')
continue
}
renewalPriceIds.push(renewalPriceId)
}
if (renewalPriceIds.length > 0) {
const toltReferral = options?.toltReferral ?? null
const cardPaymentMethods = await stripe.paymentMethods.list({
customer: customerId,
type: 'card',
})
const paymentMethod = cardPaymentMethods.data[0]
const collectionMethod = paymentMethod ? 'charge_automatically' : 'send_invoice'
const renewalSub = await stripe.subscriptions.create({
...(toltReferral && {
metadata: {
tolt_referral: toltReferral,
},
}),
customer: customerId,
collection_method: collectionMethod,
...(collectionMethod === 'charge_automatically'
? {
default_payment_method: paymentMethod.id,
}
: {
days_until_due: 5,
}),
trial_period_days: 365,
// billing_cycle_anchor: (function () {
// const date = new Date()
// date.setFullYear(date.getFullYear() + 1)
// return Math.floor(Number(date) / 1000)
// })(),
items: renewalPriceIds.map((id) => ({
price: id,
})),
})
console.info(renewalSub)
}
}
export async function getOrCreateRenewalPriceId(price: Stripe.Price) {
let product =
price.product && typeof price.product === 'object'
? (price.product as Stripe.Product)
: await stripe.products.retrieve(price.product)
if (
!product.metadata.has_renewals || // this product doesn't need renewal prices
price.type === 'recurring' || // this price is already a subscription price - not having this check might cause an infinite loop of creating prices
price.metadata.is_lifetime // there is no need for creating a subscription as this is a lifetime purchase
) {
return null
}
let renewalPriceId = price.metadata.renewal_price_id
if (!price.metadata.renewal_price_id) {
const subscriptionPrice = await stripe.prices.create({
product: typeof price.product === 'string' ? price.product : price.product.id,
currency: 'USD',
nickname: `${price.nickname} | Subscription for ${price.id} (Auto-generated)`,
recurring: { interval: 'year', interval_count: 1 },
unit_amount: price.unit_amount!, // change this to `unit_amount: price.unit_amount! / 2` for 50% prices (make sure to remove old prices to trigger this)
metadata: {
hide_from_lists: 1,
},
})
renewalPriceId = subscriptionPrice.id
await stripe.prices.update(price.id, {
metadata: {
...price.metadata,
renewal_price_id: subscriptionPrice.id,
},
})
}
return renewalPriceId
}
export async function populateStripeData() {
// products
const products = await stripe.products.list({ limit: 100 })
for (const product of products.data) {
await upsertProductRecord(product)
console.info('populated product', product.name)
}
// prices
const prices = await stripe.prices.list({ limit: 100 })
for (const price of prices.data) {
await upsertPriceRecord(price)
console.info('populated price ', price.nickname)
}
}
export const getBentoBundleZip = async () => {
const { data, error } = await supabaseAdmin.storage
.from('bento')
.download('bento-bundle.zip')
if (error) {
console.error('Error downloading Bento bundle:', error)
throw new Error('Failed to download Bento bundle')
}
return data
}
-23
View File
@@ -1,23 +0,0 @@
import type { User } from '@supabase/auth-js'
import type { Database } from '../supabase/types'
import type {
getSubscriptions,
getUserAccessInfo,
getUserDetails,
getUserThemeHistories,
} from '../user/helpers'
export type UserContextType = {
subscriptions?: Awaited<ReturnType<typeof getSubscriptions>> | null
user: User
userDetails?: Awaited<ReturnType<typeof getUserDetails>> | null
teams: {
all?: Database['public']['Tables']['teams']['Row'][] | null
orgs?: Database['public']['Tables']['teams']['Row'][] | null
personal?: Database['public']['Tables']['teams']['Row'] | null
main?: Database['public']['Tables']['teams']['Row'] | null
}
accessInfo: Awaited<ReturnType<typeof getUserAccessInfo>>
themeHistories?: Awaited<ReturnType<typeof getUserThemeHistories>>
githubUsername?: string | null
}
@@ -1,79 +0,0 @@
import { useToastController } from '@hanzogui/toast'
import { navigateToInternalPath } from '~/features/security/navigation'
import { useUser } from '../user/useUser'
import { useSupabaseClient } from './useSupabaseClient'
export const useLoginLink = () => {
const userSwr = useUser()
const supabaseClient = useSupabaseClient()
const toast = useToastController()
return {
handleLogin: async (e: any) => {
e.preventDefault()
// Open popup for GitHub auth
const width = 600
const height = 800
const left = window.screenX + (window.innerWidth - width) / 2
const top = window.screenY + (window.innerHeight - height) / 2
// Open popup with the auth URL
const popup = window.open(
`${window.location.origin}/login`,
'Login with GitHub',
`width=${width},height=${height},left=${left},top=${top},menubar=no,toolbar=no,location=no,status=no`
)
if (!popup) {
// popup was blocked, show toast and redirect after delay
toast.show('Popup blocked, redirecting to login...')
setTimeout(() => {
navigateToInternalPath('/login')
}, 2000)
return
}
const handleMessage = async (event: MessageEvent) => {
if (event.origin !== window.location.origin) return
if (event.source !== popup) return
if (!isAuthPopupMessage(event.data)) return
if (event.data.type === 'SUPABASE_AUTH_SUCCESS') {
window.removeEventListener('message', handleMessage)
await supabaseClient.auth.refreshSession()
userSwr.refresh()
} else if (event.data.type === 'SUPABASE_AUTH_ERROR') {
window.removeEventListener('message', handleMessage)
toast.show('Login failed', {
message: event.data.error || 'Please try again.',
})
}
}
window.addEventListener('message', handleMessage)
},
}
}
function isAuthPopupMessage(
data: unknown
): data is
| { type: 'SUPABASE_AUTH_SUCCESS' }
| { type: 'SUPABASE_AUTH_ERROR'; error?: string } {
if (!data || typeof data !== 'object') {
return false
}
const message = data as { type?: unknown; error?: unknown }
if (message.type === 'SUPABASE_AUTH_SUCCESS') {
return true
}
if (message.type !== 'SUPABASE_AUTH_ERROR') {
return false
}
return message.error === undefined || typeof message.error === 'string'
}
@@ -1,167 +0,0 @@
import { AuthClient, type Session } from '@supabase/auth-js'
import { useEffect, useState } from 'react'
import { useSWRConfig } from 'swr'
// Lightweight wrapper that provides the same interface as SupabaseClient
// but only includes auth functionality (no realtime/ws dependencies)
type SupabaseAuthOnlyClient = {
auth: InstanceType<typeof AuthClient>
}
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL
const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
// Initialize client eagerly - @supabase/auth-js is small (~30kb) and has no ws/realtime deps
function createClient(): SupabaseAuthOnlyClient | null {
if (typeof window === 'undefined') return null
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
console.error(`Missing supabase info`)
return null
}
const authClient = new AuthClient({
url: `${SUPABASE_URL}/auth/v1`,
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
},
storageKey: 'sb-auth-token',
storage: window.localStorage,
flowType: 'pkce',
detectSessionInUrl: false, // We handle OAuth callback manually in /auth
lockAcquireTimeout: 30000, // 30s to avoid lock steal/broken errors from tab throttling
})
return { auth: authClient }
}
let client: SupabaseAuthOnlyClient | null = null
// Get the current access token - can be called outside React
export async function getAccessToken(): Promise<string | null> {
if (typeof window === 'undefined') return null
// Ensure client is initialized
if (!client) {
client = createClient()
if (client) {
globalThis['supabaseClient'] = client
}
}
// Try via client first
if (client) {
const { data, error } = await client.auth.getSession()
if (!error && data.session) {
return data.session.access_token
}
}
// Fallback: read directly from localStorage
// This handles the case where SWR fetches before the client is fully ready
try {
const stored = localStorage.getItem('sb-auth-token')
if (stored) {
const parsed = JSON.parse(stored)
if (parsed.access_token) {
return parsed.access_token
}
}
} catch {
// ignore parse errors
}
return null
}
export function useSupabaseClient(given?: SupabaseAuthOnlyClient) {
const [current, setCurrent] = useState(() => given ?? client)
useEffect(() => {
// if we already have it in state, nothing to do
if (current) return
// if module-level client exists, sync it to state
if (client) {
setCurrent(client)
return
}
// otherwise create it
client = createClient()
if (client) {
globalThis['supabaseClient'] = client
setCurrent(client)
}
}, [current])
return current as SupabaseAuthOnlyClient
}
export function useSupabaseSession(client?: SupabaseAuthOnlyClient) {
const supabase = useSupabaseClient(client)
const [session, setSession] = useState<Session | null>(null)
useEffect(() => {
if (!supabase) return // Client not ready yet
const run = async () => {
const reply = await supabase.auth.getSession()
if (reply.error) {
console.error(`Error authenticating`, reply.error)
setSession(null)
return
}
// Always update session state (including when null after logout)
setSession(reply.data.session)
}
run()
// Listen for auth changes to update session state
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => {
setSession(session)
})
return () => subscription.unsubscribe()
}, [supabase])
return session
}
export const useSupabase = () => {
const supabase = useSupabaseClient()
const session = useSupabaseSession(supabase)
const swrClient = useSWRConfig()
useEffect(() => {
if (!supabase) return
const listener = supabase.auth.onAuthStateChange(async (event, currentSession) => {
// Skip events that shouldn't trigger user data refetch:
// - TOKEN_REFRESHED: fires periodically (~hourly), would cause unnecessary refetches
// - INITIAL_SESSION: fires on page load, SWR already handles initial fetch
if (event === 'TOKEN_REFRESHED' || event === 'INITIAL_SESSION') {
return
}
// Skip if same user already signed in (prevents duplicate fetches)
if (event === 'SIGNED_IN') {
if (session?.user.id === currentSession?.user.id) {
return
}
}
await swrClient.mutate('user')
})
return () => listener.data.subscription.unsubscribe()
}, [supabase, session])
return {
supabase,
session,
}
}
@@ -1,10 +0,0 @@
import { createUseStore } from '@hanzogui/use-store'
class BentoStore {
heroVisible = true
heroHeight = 800
disableTint = true
disableCustomTheme = false
}
export const useBentoStore = createUseStore(BentoStore)
@@ -1,114 +0,0 @@
import { supabaseAdmin } from '~/features/auth/supabaseAdmin'
import { ProductName } from '~/shared/types/subscription'
import { getSubscriptions } from '../user/helpers'
/**
* Check if a user has Pro access via subscription, legacy product ownership, or whitelist
* Runs all checks in parallel for speed
*/
export const hasProAccess = async (userId: string) => {
// run all checks in parallel
const [subscriptions, legacyAccess, whitelistAccess] = await Promise.all([
getSubscriptions(userId),
hasLegacyAccess(userId),
hasWhitelistAccess(userId),
])
// check whitelist first (fastest path for gifted users)
if (whitelistAccess) {
return true
}
// check for current subscription-based access (active or trialing)
const validProProducts = [
ProductName.GuiPro,
ProductName.GuiProV2,
ProductName.GuiProV2Upgrade,
ProductName.GuiSupportDirect,
ProductName.GuiSupportSponsor,
]
const hasSubscriptionAccess = subscriptions?.some((subscription) => {
const isActiveOrTrialing =
subscription.status === 'active' || subscription.status === 'trialing'
const hasValidProduct = subscription.subscription_items?.some((item) => {
const productName = item.price?.product?.name
return validProProducts.some((product) => productName?.includes(product))
})
return isActiveOrTrialing && hasValidProduct
})
if (hasSubscriptionAccess) {
return true
}
// check for legacy lifetime access
return legacyAccess
}
const hasLegacyAccess = async (userId: string) => {
const result = await supabaseAdmin
.from('product_ownership')
.select(`
*,
prices (
*,
products (
*
)
)
`)
.eq('user_id', userId)
if (!result?.data?.length) {
return false
}
// check for direct Bento product ownership (legacy)
const hasDirectBentoAccess = result.data.some(
(ownership) => ownership.prices?.products?.name === ProductName.GuiBento
)
if (hasDirectBentoAccess) {
return true
}
// check for legacy lifetime purchases (product metadata.is_lifetime = "1")
const hasLifetimeAccess = result.data.some((ownership) => {
const productMetadata = ownership.prices?.metadata as Record<string, any> | null
return productMetadata?.is_lifetime === '1'
})
return hasLifetimeAccess
}
/**
* Check if user is on the pro whitelist by their github username
* Uses a single joined query for efficiency
*/
const hasWhitelistAccess = async (userId: string): Promise<boolean> => {
// get github username and check whitelist in parallel via a single query pattern
const userResult = await supabaseAdmin
.from('users_private')
.select('github_user_name')
.eq('id', userId)
.single()
const githubUsername = userResult.data?.github_user_name
if (!githubUsername) {
return false
}
// check if they're on the whitelist (case-insensitive)
const whitelistResult = await supabaseAdmin
.from('pro_whitelist')
.select('id')
.ilike('github_username', githubUsername)
.limit(1)
.maybeSingle()
return !!whitelistResult.data
}
// backwards compat alias
export const hasBentoAccess = hasProAccess
@@ -1,23 +0,0 @@
import { useTint } from '@hanzogui/logo'
import { useBentoStore } from './BentoStore'
import { useThemeBuilderStore } from '../studio/theme/store/ThemeBuilderStore'
export const useBentoTheme = () => {
const bentoStore = useBentoStore()
const { tint } = useTint()
const store = useThemeBuilderStore()
const themeName: any = `studiodemointernal${store.themeSuiteUID}`
const enabled = !bentoStore.disableCustomTheme && store.themeSuiteUID
return {
bgColor: themeName ? '$color1' : '$colorBg',
enabled,
themeName: enabled
? bentoStore.disableTint
? themeName
: `${themeName}_accent`
: bentoStore.disableTint
? tint
: null,
} as const
}
-471
View File
@@ -1,471 +0,0 @@
// @ts-ignore
import * as postmark from 'postmark'
import { serverEnv } from '../api/serverEnv'
const serverToken = serverEnv('POSTMARK_SERVER_TOKEN')!
export const trackedTransactionalEmail = {
MessageStream: 'outbound',
TrackOpens: true,
TrackLinks: postmark.Models.LinkTrackingOptions.HtmlAndText,
} as const
export const trackedBroadcastEmail = {
MessageStream: 'broadcast',
TrackOpens: true,
TrackLinks: postmark.Models.LinkTrackingOptions.HtmlAndText,
} as const
if (process.env.NODE_ENV === 'production') {
if (!serverToken) {
throw new Error(`No POSTMARK_SERVER_TOKEN env var is set`)
}
}
export function sendProductPurchaseEmail(
email: string,
args: { name: string; product_name: string }
) {
if (process.env.NODE_ENV !== 'production') {
console.info(`Not sending email to ${email} since we're not on prod.`)
return
}
const client = new postmark.ServerClient(serverToken)
const htmlBody = wrapEmail(`
<h1>Hey ${args.name}! 👋</h1>
<p>Thank you for your purchase of ${args.product_name}!</p>
<h2>Important: Accept Your GitHub Team Invite</h2>
<p>To access the private repositories (Takeout, Takeout Static, Bento, and more), you need to <strong>accept the team invite</strong> that should be in your inbox from GitHub.</p>
<p>If you don't see the invite email, you can re-send it by:</p>
<ol>
<li>Logging into your account at <a href="https://hanzogui.dev">hanzogui.dev</a></li>
<li>Going to your Account settings</li>
<li>Clicking the "Re-send GitHub Invite" button</li>
</ol>
${whatYouGetSection}
<p>If you have any questions or need help, just reply to this email or reach out at <a href="mailto:[email protected]">[email protected]</a>.</p>
<p><strong>P.S.</strong> If you haven't already, be sure to join our Discord community!</p>
${emailFooter}
`)
return client.sendEmail({
...trackedTransactionalEmail,
From: '[email protected]',
To: email,
Subject: `Welcome to ${args.product_name}!`,
HtmlBody: htmlBody,
})
}
export function sendProductRenewalEmail(
email: string,
args: { name: string; product_name: string; amount_due?: number }
) {
if (process.env.NODE_ENV !== 'production') {
console.info(`Not sending email to ${email} since we're not on prod.`)
return
}
const client = new postmark.ServerClient(serverToken)
const amountText = args.amount_due
? `<strong>$${(args.amount_due / 100).toFixed(2)}</strong>`
: 'the renewal amount'
const htmlBody = wrapEmail(`
<h1>Hey ${args.name}!</h1>
<p><strong>Your subscription to ${args.product_name} will renew in approximately 7 days for ${amountText}.</strong></p>
<p>If you'd like to continue, no action is needed — your subscription will renew automatically.</p>
<p>If you'd like to cancel before the renewal, you can do so from your account page:</p>
<div class="cta-container">
<a href="https://hanzogui.dev/account" class="cta-button">Manage Subscription</a>
</div>
${whatYouGetSection}
<p>If you have any questions, just reply to this email or reach out at <a href="mailto:[email protected]">[email protected]</a>.</p>
${emailFooter}
`)
return client.sendEmail({
...trackedTransactionalEmail,
From: '[email protected]',
To: email,
Subject: `Your ${args.product_name} subscription renews in ~7 days`,
HtmlBody: htmlBody,
})
}
// shared email styles
const emailStyles = `
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
h1 { color: #000; }
h2 { color: #333; margin-top: 30px; }
.cta-button { background-color: #000; color: #fff !important; padding: 14px 28px; text-decoration: none; border-radius: 8px; display: inline-block; margin: 10px 5px; font-weight: 600; }
.cta-button-secondary { background-color: #666; }
.coupon-box { background: #ffeb3b; color: #000; padding: 20px; border-radius: 12px; text-align: center; margin: 24px 0; }
.coupon-code { font-size: 28px; font-weight: bold; letter-spacing: 2px; margin: 4px 0; font-family: monospace; }
.coupon-discount { font-size: 18px; margin: 0; }
ul { padding-left: 20px; }
li { margin: 8px 0; }
.footer { margin-top: 40px; padding-top: 20px; border-top: 1px solid #eee; color: #666; font-size: 14px; }
.cta-container { text-align: center; margin: 30px 0; }
`
// shared "what you get" content - keep this updated!
const whatYouGetSection = `
<h2>... we've been reborn</h2>
<p>We've been incredibly busy rethinking what a modern stack means, and rebuilding it to a much higher degree of quality:</p>
<ul>
<li><strong>Gui 2</strong> - Better in every way: new components, re-written docs, easier install and setup, thousands of new tests. <strong><a href="https://hanzogui.dev/blog/version-two">Read the announcement &rarr;</a></strong></li>
<li><strong>One v1</strong> - One is now stable and works seamlessly with Metro, plus has more features than your favorite web framework. <strong><a href="https://onestack.dev/blog/version-one-rc1">Read about One &rarr;</a></strong></li>
<li><strong>Takeout 2</strong> - A huge amount of effort went into this new stack. Gui 2, One 1, and Zero. 95+ Lighthouse scores, fully shared code, tons of AI skills and documentation. <strong><a href="https://hanzogui.dev/takeout">More info</a></strong> | <strong><a href="https://takeout.hanzogui.dev">Demo</a></strong></li>
<li><strong>Takeout Static</strong> - A new simplified web-only starter with MDX blog/docs and 100 Lighthouse.</li>
<li><strong>Bento Components</strong> - Rewritten for v2 with new components and more polish, updated libraries. Includes access to the private <a href="https://github.com/hanzogui/bento">Bento source repo</a>.</li>
<li><strong>Unlimited Team Members</strong> - Share access with your whole team.</li>
<li><strong>AI Theme Generator</strong> - Opus-powered /theme generation for custom designs.</li>
</ul>
`
// shared intro - heartfelt message about the team's work
const emailIntro = `
<p>I want to thank you so much for supporting our small team. We've been working very hard to not just rethink Gui, but One and Takeout and try to put together something genuinely beautiful and groundbreaking. If you take anything from this email, I hope you check out <a href="https://takeout.hanzogui.dev">the new Takeout</a>. It's a product of love from our small team, and we could use support now more than ever to continue building dev tools that are simple, joyful, and surprisingly effective.</p>
`
// shared consulting callout
const emailConsulting = `
<p style="text-align: center; color: #666; border-top: 1px solid #eee; border-bottom: 1px solid #eee; padding: 40px 0; margin: 24px 0;"><strong>We're now available to consult @ <a href="https://addeven.com">Add Even</a></strong></p>
`
// shared CTA buttons
const emailCTAs = `
<div class="cta-container">
<a href="https://hanzogui.dev/account" class="cta-button">Upgrade</a>
<a href="https://takeout.hanzogui.dev" class="cta-button" style="background-color: #8b3a3a;">Takeout</a>
<a href="https://hanzogui.dev/blog/version-two" class="cta-button" style="background-color: #5c4033;">v2</a>
</div>
`
// shared footer
const emailFooter = `
<div class="footer">
<p>Thanks for being part of Gui!<br><strong>- Nate & the Gui Team</strong></p>
</div>
`
// email wrapper helper
function wrapEmail(body: string) {
return `
<!DOCTYPE html>
<html>
<head>
<style>${emailStyles}</style>
</head>
<body>
${body}
</body>
</html>
`.trim()
}
/**
* Send renewal email to legacy subscribers before the V2 rollout.
* Their renewal gets 30% off, and they also get a shareable coupon code for manual purchases.
*/
export function sendV1UpgradeEmail(
email: string,
args: { name: string; subscriptionId: string; amount_due?: number }
) {
if (process.env.NODE_ENV !== 'production') {
console.info(`Not sending V1 upgrade email to ${email} since we're not on prod.`)
return
}
const client = new postmark.ServerClient(serverToken)
const couponCode = 'RENEWAL30'
const amountText = args.amount_due
? `<strong>$${(args.amount_due / 100).toFixed(2)}</strong>`
: 'the renewal amount'
const htmlBody = wrapEmail(`
<h1>Hey ${args.name}!</h1>
<p><strong>Your Gui Pro subscription will renew in approximately 7 days for ${amountText}.</strong></p>
<p>We've applied <strong>30% off</strong> to your upcoming renewal.</p>
<p>If you'd like to continue, no action is needed. If you'd like to cancel before renewal, you can do so from your account page:</p>
<div class="cta-container">
<a href="https://hanzogui.dev/account" class="cta-button">Manage Subscription</a>
</div>
<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;" />
<h2>Consider upgrading to Takeout 2</h2>
<p>We've been working hard on the next generation. <strong>Takeout 2</strong> features Gui 2, One 1, and Zero — a more realtime, responsive and Rails-like starter for React Native + Web.</p>
<ul>
<li><strong>Takeout 2</strong> - >95 Lighthouse, tons of scripts, helpers, hooks and UI in the box.</li>
<li><strong>Takeout Static</strong> - Simplified web-only starter with 100 Lighthouse.</li>
<li><strong>Unlimited Team Members</strong> - No more per-seat pricing.</li>
</ul>
<p>If you want to buy another project or share the discount with a friend, use this code at checkout:</p>
<div class="coupon-box">
<div class="coupon-discount">30% off</div>
<div class="coupon-code">${couponCode}</div>
</div>
<p>Check out the demo: <a href="https://takeout.hanzogui.dev">takeout.hanzogui.dev</a></p>
<p>If you have any questions, just reply to this email or reach out at <a href="mailto:[email protected]">[email protected]</a>.</p>
${emailFooter}
`)
return client.sendEmail({
...trackedTransactionalEmail,
From: '[email protected]',
To: email,
Subject: 'Your Gui Pro subscription renews in ~7 days',
HtmlBody: htmlBody,
})
}
/**
* Legacy confirmation email kept for backwards compatibility with old V2-renewal links.
*/
export function sendV2RenewalEnabledEmail(email: string, args: { name: string }) {
if (process.env.NODE_ENV !== 'production') {
console.info(
`Not sending V2 renewal enabled email to ${email} since we're not on prod.`
)
return
}
const client = new postmark.ServerClient(serverToken)
const couponCode = 'RENEWAL30'
const htmlBody = wrapEmail(`
<h1>Your Renewal Discount Is Set</h1>
<p>Hey ${args.name},</p>
<p>We've applied <strong>30% off</strong> to your renewal.</p>
<p>If you want to buy another project or share the discount with a friend, use this code at checkout:</p>
<div class="coupon-box">
<div class="coupon-discount">30% off</div>
<div class="coupon-code">${couponCode}</div>
</div>
<h2>What happens next?</h2>
<p>Your current subscription will renew automatically with the discount applied unless you cancel it first.</p>
<p>You'll get access to:</p>
<ul>
<li><strong>Takeout 2</strong> - The complete React Native + Web starter with Gui 2, One 1, and Zero</li>
<li><strong>Takeout Static</strong> - Web-only starter with 100 Lighthouse score</li>
<li><strong>Bento Components</strong> - Full source repo access</li>
<li><strong>Unlimited Team Members</strong> - No per-seat pricing</li>
<li><strong>1 Year of Updates</strong> - Included with your purchase</li>
</ul>
<p>You can manage your subscription anytime from your <a href="https://hanzogui.dev/account">account page</a>.</p>
<p>If you have any questions, just reply to this email or reach out at <a href="mailto:[email protected]">[email protected]</a>.</p>
${emailFooter}
`)
return client.sendEmail({
...trackedTransactionalEmail,
From: '[email protected]',
To: email,
Subject: 'Your Gui renewal discount is set',
HtmlBody: htmlBody,
})
}
/**
* @deprecated Use sendV1UpgradeEmail instead
*/
export const sendV1ExpirationEmail = sendV1UpgradeEmail
/**
* Send payment method reminder to users whose subscriptions are about to renew
* but don't have a payment method attached
*/
export function sendPaymentMethodReminderEmail(
email: string,
args: { name: string; daysUntilExpiry: number; isApology?: boolean }
) {
if (process.env.NODE_ENV !== 'production') {
console.info(
`Not sending payment reminder email to ${email} since we're not on prod.`
)
return
}
const client = new postmark.ServerClient(serverToken)
const apologySection = args.isApology
? `
<p style="background: #f5f5f5; padding: 16px; border-radius: 8px;">
Our new Pro package has changed and so we've disabled auto-renew. For supporting our small team we'd like to thank you.
</p>
`
: ''
const urgencyText =
args.daysUntilExpiry <= 0
? 'Your subscription has expired! But no sweat, we have some amazing new stuff for you to take a look at.'
: args.daysUntilExpiry <= 3
? `Your subscription expires in ${args.daysUntilExpiry} day${args.daysUntilExpiry === 1 ? '' : 's'}!`
: `Your subscription renews in ${args.daysUntilExpiry} days.`
const htmlBody = wrapEmail(`
${emailIntro}
<p><strong>${urgencyText}</strong></p>
${emailCTAs}
${apologySection}
<div class="coupon-box">
<div class="coupon-discount">30% off for returning</div>
<div class="coupon-code">RENEWAL30</div>
<p style="margin: 4px 0 0; font-size: 14px;">Stacks with parity pricing!</p>
</div>
${emailConsulting}
${whatYouGetSection}
<p>Questions? Just reply to this email or reach out at <a href="mailto:[email protected]">[email protected]</a>.</p>
${emailFooter}
`)
return client.sendEmail({
...trackedBroadcastEmail,
From: '[email protected]',
To: email,
Subject: 'A genuine thank you',
HtmlBody: htmlBody,
})
}
/**
* Send confirmation email when a user cancels their subscription
*/
export function sendCancellationEmail(
email: string,
args: { name: string; periodEnd: string }
) {
if (process.env.NODE_ENV !== 'production') {
console.info(`Not sending cancellation email to ${email} since we're not on prod.`)
return
}
const client = new postmark.ServerClient(serverToken)
const endDate = new Date(args.periodEnd).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
const htmlBody = wrapEmail(`
<h1>Your subscription has been cancelled</h1>
<p>Hey ${args.name},</p>
<p>Your Gui Pro subscription has been cancelled. You'll retain full access until <strong>${endDate}</strong>.</p>
<p>If this was a mistake, you can re-subscribe anytime from your account page:</p>
<div class="cta-container">
<a href="https://hanzogui.dev/account" class="cta-button">Go to Account</a>
</div>
<p>We'd love to have you back. If there's anything we could have done better, just reply to this email — we read every response.</p>
${emailFooter}
`)
return client.sendEmail({
...trackedTransactionalEmail,
From: '[email protected]',
To: email,
Subject: 'Your Gui Pro subscription has been cancelled',
HtmlBody: htmlBody,
})
}
/**
* Send email when a payment fails
*/
export function sendPaymentFailedEmail(
email: string,
args: { name: string; amount?: number }
) {
if (process.env.NODE_ENV !== 'production') {
console.info(`Not sending payment failed email to ${email} since we're not on prod.`)
return
}
const client = new postmark.ServerClient(serverToken)
const amountText = args.amount ? ` of $${(args.amount / 100).toFixed(2)}` : ''
const htmlBody = wrapEmail(`
<h1>Payment failed</h1>
<p>Hey ${args.name},</p>
<p>We weren't able to process your payment${amountText} for your Gui Pro subscription. Stripe will retry the payment automatically, but if it continues to fail your subscription may be interrupted.</p>
<p>Please update your payment method from your account page:</p>
<div class="cta-container">
<a href="https://hanzogui.dev/account" class="cta-button">Update Payment Method</a>
</div>
<p>If you have any questions, just reply to this email or reach out at <a href="mailto:[email protected]">[email protected]</a>.</p>
${emailFooter}
`)
return client.sendEmail({
...trackedTransactionalEmail,
From: '[email protected]',
To: email,
Subject: 'Action needed: payment failed for your Gui Pro subscription',
HtmlBody: htmlBody,
})
}
@@ -1,553 +0,0 @@
import { serverEnv } from '../api/serverEnv'
export type GithubSponsorshipStatus =
| {
hasSponsorAccess: false
sponsorshipStatus: 'not-sponsor'
meta: {
name: string
id: string
}
}
| {
hasSponsorAccess: true
sponsorshipStatus: 'sponsor'
meta: {
name: string
id: string
}
tier: {
id: string
name: string
}
}
| {
hasSponsorAccess: true
sponsorshipStatus: 'whitelist'
meta: {
name: string
id: string
}
}
export type GithubAccessStatus = {
personal: GithubSponsorshipStatus
orgs: GithubSponsorshipStatus[]
}
// sponsor checks reuse the admin token (the old read-only GITHUB_TOKEN was retired)
const GITHUB_ADMIN_TOKEN = serverEnv('GITHUB_ADMIN_TOKEN')
// whitelisting uniswap org for feedback
const whitelistOrgs = {
uniswap: true,
codingscape: true,
}
export function checkOrgSponsor(orgLogin: string) {
return whitelistOrgs[orgLogin]
}
const uniswapGithubUsers = [
'zzmp',
'moodysalem',
'ianlapham',
'crowdin-bot',
'spenciefy',
'thomasthachil',
'vm',
'tinaszheng',
'leggechr',
'danielcolinjames',
'JFrankfurt',
'tarikbellamine',
'cbachmeier',
'just-toby',
'adjkant',
'NoahZinsmeister',
'lynnshaoyu',
'JackShort',
'cartcrom',
'i1skn',
'cmcewen',
'MatiPl01',
'natew',
'dalmendray',
'github-actions[bot]',
'felipebrahm',
'callil',
'clayton1110',
'grabbou',
'garysye',
'chelsywu',
'jmrossy',
'haydenadams',
'andrewting19',
'Tott0',
'chikeichan',
'LunrEclipse',
'hello-happy-puppy',
'lint-action',
'kayleegeorge',
'kristiehuang',
'kennyt',
'gbugyis',
'PiotrWszolek',
'erichuang27',
'pp-hh-ii-ll',
'dependabot[bot]',
'lavalamp-',
'unipadmini',
'andy-reed',
'aballerr',
'willhennessy',
'brnunes',
'gnewfield',
'mczernek',
'chenxsan',
'yannie-yip',
'rossbulat',
'wojtus7',
'matteenm',
'brendanww',
'PaulRBerg',
'mirshko',
'mr-uniswap',
'Jesse-Sawa',
'Rachel-Eichenberger',
'carlosdp',
'darkwing',
'brunocrosier',
'snreynolds',
'uniyj',
'YutaSugimura',
'ChristophSiegenthaler',
'hav-noms',
'mattbspector',
'sarobolket',
'zhyd1997',
'willpote',
'cbd',
'bkrochta',
'Sowiedu',
'justindomingue',
'artistic709',
'dy',
'tpmccallum',
'tsudmi',
'julianjca',
'bgits',
'badkk',
'lukedonato',
'jab416171',
'hzhu',
'jochenboesmans',
'graemeblackwood',
'moontools-hyperion',
'eccentricexit',
'MicahZoltu',
'CryptoCatVC',
'wjmelements',
'brianmcmichael',
]
const codinscapeusers = ['NathanBeesley']
const callstackusers = ['troZee']
export const whitelistBentoUsernames = new Set([
// team
'baronha',
'poteboy',
'zetavg',
'anhquan291',
'Bankilo',
'meal',
'Bar-Cet',
'kamilzielinski97',
'WeronikaKosniowska',
'patrycjalobodzinska26',
// stephen_S
'Stephen-Song',
// closedloop:
'wongk',
'dakotaodell',
'dougzor',
'[email protected]',
// JBR
'mattleesounds',
// livepeer
'0xcadams',
// ibot
'rbondoc96',
'MOFFROUGH',
'Jasonej',
'erik-perri',
'NilsDelaguardia',
'andresvega-sourcetoad',
'chrisr-st',
'justinvoelkel',
'jdmonm',
'michalm-st',
'charlesreffett',
'bradtoad',
'michal-sourcetoad',
'jpaz91',
// aldoMC
'gusribeiro',
'guilhermebruzzi',
'Brunier',
'jlbovenzo',
'gtkatakura',
'gsasouza',
'thiagokpelo',
'verhagenkava',
// RichardTunstall
'ads102003',
'FateFirst',
// iBotPeaches
'iBotPeaches',
// [email protected]
'upp22',
'[email protected]',
// codingscape
...codinscapeusers,
])
export const whitelistGithubUsernames = [
'natew',
// 'alitnk', // commented out to test `takeout -> studio` access
'benschac',
'mohamadchehab',
'bidah',
'szymonrybczak',
// gather team member - https://discord.com/channels/909986013848412191/1125830682661363794/1156983395566497834
'pkretzschmar',
// cooking
'natalie-zamani',
// codingscape
...codinscapeusers,
// uniswap:
...uniswapGithubUsers,
// callstack
...callstackusers,
// Triba
'Awelani-Triba',
// JBR
'mattleesounds',
// michak
'KristoT',
]
export const checkForSponsorship = async (
login: string,
userToken: string
): Promise<GithubAccessStatus> => {
const orgs = await getOrgs(userToken)
// const personalStatus = await isLoginSponsor(login)
// const allOrgsStatus = await Promise.all(
// orgs.map(async (org) => {
// return {
// ...org,
// isSponsoring: await isLoginSponsor(org.login),
// }
// })
// )
// const orgsStatus = allOrgsStatus.filter((org) => org.)
// TODO: can probably do all of these on one github req - see: graphql alias
const isOrgSponsor = orgs.some((org) => checkOrgSponsor(org.login))
return {
personal: await isLoginSponsor(login, isOrgSponsor),
orgs: await Promise.all(orgs.map(async (org) => isLoginSponsor(org.login))),
}
}
const isLoginSponsor = async (
login: string,
isOrgSponsor = false
): Promise<GithubSponsorshipStatus> => {
const res = await fetch('https://api.github.com/graphql', {
method: 'POST',
body: JSON.stringify({
query: `
query CheckForSponsorship($sponsorLogin: String!) {
repositoryOwner(login: $sponsorLogin) {
... on Organization {
id
name
}
... on User {
id
name
}
... on Sponsorable {
sponsorshipForViewerAsSponsorable(activeOnly: true) {
tier {
id
name
monthlyPriceInCents
description
}
}
}
}
}
`,
variables: {
sponsorLogin: login,
},
}),
headers: {
Authorization: `bearer ${GITHUB_ADMIN_TOKEN}`,
},
})
const json = await res.json()
const sponsorId = json.data?.repositoryOwner.id
const sponsorName = json.data?.repositoryOwner.name
const isSponsoring = !!json.data?.repositoryOwner?.sponsorshipForViewerAsSponsorable
const tier = json.data?.repositoryOwner?.sponsorshipForViewerAsSponsorable?.tier
if (isOrgSponsor || whitelistGithubUsernames.includes(login)) {
return {
hasSponsorAccess: true,
sponsorshipStatus: 'whitelist',
meta: {
id: sponsorId,
name: sponsorName,
},
}
}
if (isSponsoring) {
// const tierIncludesStudio: boolean = tier.description.toLowerCase().includes('studio')
return {
hasSponsorAccess: true,
sponsorshipStatus: 'sponsor',
meta: {
id: sponsorId,
name: sponsorName,
},
tier: {
id: tier.id,
name: tier.name,
},
}
}
return {
hasSponsorAccess: false,
sponsorshipStatus: 'not-sponsor',
meta: {
id: sponsorId,
name: sponsorName,
},
}
// const queueDate = sponsorshipDateMap[tier.id]
// const queuePermission: boolean = queueDate < new Date()
// return {
// sponsoring: isSponsoring,
// studio:
// tierIncludesStudio && queuePermission
// ? {
// access: true,
// }
// : {
// access: false,
// accessDate: queueDate,
// message: !tierIncludesStudio
// ? "Your sponsorship doesn't include Studio access"
// : `You are a sponsor but you can't access Studio yet. Upgrade sponsorship tier for earlier access!`,
// },
// }
}
const getOrgs = async (
token: string
): Promise<{ id: string; login: string; avatarUrl: string; name: string }[]> => {
const res = await fetch('https://api.github.com/graphql', {
method: 'POST',
body: JSON.stringify({
query: `query GetOrganizations {
viewer {
organizations(first: 100) {
nodes {
id
login
avatarUrl
name
}
}
}
}`,
variables: {},
}),
headers: {
Authorization: `bearer ${token}`,
},
})
const json = await res.json()
return json?.data?.viewer?.organizations?.nodes ?? []
}
/**
* Add a user to a GitHub team
* @see https://docs.github.com/en/rest/teams/members?apiVersion=2022-11-28#add-or-update-team-membership-for-a-user
*/
export const addUserToTeam = async (
teamSlug: string,
userLogin: string,
orgName = 'hanzogui',
role: 'member' | 'maintainer' = 'member'
) => {
console.info(
`Claim: addUserToTeam adding ${userLogin} to ${orgName}/${teamSlug} with role ${role}`
)
try {
const res = await fetch(
`https://api.github.com/orgs/${orgName}/teams/${teamSlug}/memberships/${userLogin}`,
{
method: 'PUT',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${GITHUB_ADMIN_TOKEN}`,
'X-GitHub-Api-Version': '2022-11-28',
},
body: JSON.stringify({ role }),
}
)
if (!res.ok) {
const error = await res.text()
console.error(`Claim: addUserToTeam failed: ${res.status} ${error}`)
throw new Error(`Failed to add user to team: ${res.status} ${error}`)
}
const data = await res.json()
console.info(`Claim: addUserToTeam succeeded (state: ${data.state})`)
return data
} catch (err) {
console.error(`Claim: addUserToTeam Error: ${err}`)
throw err
}
}
/**
* Remove a user from a GitHub team
* @see https://docs.github.com/en/rest/teams/members?apiVersion=2022-11-28#remove-team-membership-for-a-user
*/
export const removeUserFromTeam = async (
teamSlug: string,
userLogin: string,
orgName = 'hanzogui'
) => {
console.info(
`Claim: removeUserFromTeam removing ${userLogin} from ${orgName}/${teamSlug}`
)
try {
const res = await fetch(
`https://api.github.com/orgs/${orgName}/teams/${teamSlug}/memberships/${userLogin}`,
{
method: 'DELETE',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${GITHUB_ADMIN_TOKEN}`,
'X-GitHub-Api-Version': '2022-11-28',
},
}
)
if (res.status === 204) {
console.info(`Claim: removeUserFromTeam succeeded`)
} else if (res.status === 404) {
console.info(`Claim: removeUserFromTeam user was not a member`)
} else {
const error = await res.text()
console.error(`Claim: removeUserFromTeam failed: ${res.status} ${error}`)
throw new Error(`Failed to remove user from team: ${res.status} ${error}`)
}
} catch (err) {
console.error(`Claim: removeUserFromTeam Error: ${err}`)
throw err
}
}
/**
* Check if a user is a member of a GitHub team
* @see https://docs.github.com/en/rest/teams/members?apiVersion=2022-11-28#get-team-membership-for-a-user
*/
export const checkIfUserIsTeamMember = async (
teamSlug: string,
userLogin: string,
orgName = 'hanzogui'
): Promise<{
isMember: boolean
state?: 'active' | 'pending'
role?: 'member' | 'maintainer'
}> => {
console.info(`Checking if ${userLogin} is a member of ${orgName}/${teamSlug}`)
try {
const res = await fetch(
`https://api.github.com/orgs/${orgName}/teams/${teamSlug}/memberships/${userLogin}`,
{
method: 'GET',
headers: {
Accept: 'application/vnd.github+json',
Authorization: `Bearer ${GITHUB_ADMIN_TOKEN}`,
'X-GitHub-Api-Version': '2022-11-28',
},
}
)
if (res.status === 200) {
const data = await res.json()
console.info(`${userLogin} is a member of ${teamSlug} (state: ${data.state})`)
return {
isMember: true,
state: data.state,
role: data.role,
}
} else if (res.status === 404) {
console.info(`${userLogin} is not a member of ${teamSlug}`)
return { isMember: false }
} else {
const errorData = await res.json().catch(() => ({}))
console.error(
`Error checking team membership: ${res.status} ${res.statusText}`,
errorData
)
return { isMember: false }
}
} catch (err) {
console.error(`Error checking if user is team member: ${err}`)
return { isMember: false }
}
}
@@ -1,21 +0,0 @@
import { YStack } from '@hanzo/gui'
import { forwardRef } from 'react'
export const AddEvenBrandIcon = forwardRef(({ scale = 1 }: { scale?: number }, ref) => (
<YStack render="span" ref={ref as any} p="$4" m="$-4">
<svg
width={20 * scale}
height={20 * scale}
viewBox="0 0 32 32"
xmlns="http://www.w3.org/2000/svg"
>
<g transform="translate(4, 8) scale(0.06)">
<path
fill="var(--color)"
fillRule="nonzero"
d="M110.974617,0.83051025 L164.531932,22.5677982 L163.451206,25.0246471 C156.322012,41.3011818 148.31895,60.9313846 139.442018,83.9152554 L151.582448,88.6777502 L205.021338,107.921064 C274.63986,134.204659 326.845832,155.024756 361.639254,170.381356 L339.444331,224 C298.056696,206.180791 238.348377,182.73844 160.319374,153.672946 C142.24185,147.083362 128.527755,142.025713 119.177088,138.500001 L117.705468,142.882479 C108.538,170.248115 102.273563,190.202543 98.9121583,202.745763 L43.42485,183.423729 L44.5888355,179.590871 C49.5980948,163.207675 56.125909,143.231057 64.1722783,119.661018 C43.2640172,112.898306 21.8732578,106.779662 0,101.305086 L21.712425,46.2372896 L25.0638016,47.1551196 C40.1313731,51.3207005 59.9225087,57.2944181 84.4372083,65.0762725 L86.4376464,59.8865674 C96.7483141,33.2297608 104.927304,13.5444085 110.974617,0.83051025 Z M219.949475,0 C281.110341,18.2497397 341.127183,41.6151664 400,70.0962803 L375.949474,122.906782 C314.837595,94.0251589 254.803915,71.1424888 195.848432,54.2587717 L219.949475,0 Z"
/>
</g>
</svg>
</YStack>
))
@@ -1,18 +0,0 @@
import { YStack } from '@hanzo/gui'
import { forwardRef } from 'react'
export const BentoIcon = forwardRef(
({ bright, scale = 1 }: { bright?: boolean; scale?: number }, ref) => (
<YStack render="span" ref={ref as any} p="$4" m="$-4">
<svg width={25 * scale} height={25 * scale} viewBox="0 0 590 590">
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<path
d="M331.699219,77 C397.585791,77 553.867188,126 591,203.804688 C597.789062,232.210938 591,365.976563 591,380.359781 C591,394.743 588.570152,411.629121 568.416463,427.735005 C548.262774,443.840889 460.015487,502.181785 392.264876,533.765268 C324.514266,565.34875 279.347192,549.557009 243.213533,533.765268 C207.079875,517.973527 62.5452392,434.502894 51.2534708,427.735005 C39.9617024,420.967116 36.6786837,413.052771 35.444995,396.151522 C34.6474882,385.225863 35.1101929,333.807432 35.3889527,284.149169 C37.6783031,285.973204 40.2636523,287 43,287 C52.3888407,287 60,274.911688 60,260 C60,245.088312 52.3888407,233 43,233 C40.3287362,233 37.8013732,233.978533 35.5522626,235.722216 C35.5498644,226.718685 35.5181972,218.410244 35.444995,211.162553 C34.896689,156.875182 265.812646,77 331.699219,77 Z M105.547166,341.605381 C102.301952,340.935123 99.1278339,343.022538 98.457576,346.267753 L98.457576,346.267753 L93.4534253,370.496548 C93.4324234,370.598234 93.4140645,370.700448 93.3983673,370.803087 C92.8974061,374.07871 95.1467137,377.140236 98.4223367,377.641197 C117.363195,380.537939 133.101925,385.192412 145.638528,391.604615 C157.838745,397.844764 173.79093,408.083992 193.495082,422.322299 C194.228803,422.852489 195.058395,423.235332 195.937901,423.449618 C199.694012,424.364775 203.480823,422.061726 204.395984,418.305615 L204.395984,418.305615 L211.065504,390.931656 C211.185397,390.439573 211.258289,389.937216 211.283199,389.431351 C211.500497,385.018419 208.099265,381.264877 203.686333,381.047579 L203.686333,381.047579 L148.075583,378.309236 L108.318088,342.995439 C107.532163,342.297356 106.576628,341.818004 105.547166,341.605381 Z M313,326 C295.879173,326 282,343.460895 282,365 C282,386.539105 295.879173,404 313,404 C330.120827,404 344,386.539105 344,365 C344,343.460895 330.120827,326 313,326 Z"
id="Path-22"
fill={bright ? `var(--color10)` : `var(--color)`}
></path>
</g>
</svg>
</YStack>
)
)
@@ -1,136 +0,0 @@
import { YStack } from '@hanzo/gui'
export const TakeoutIcon = YStack.styleable((props, ref) => (
<YStack {...props} ref={ref as any} p="$4" m="$-4">
<svg
width="24px"
height="24px"
viewBox="0 0 128 128"
aria-label="Chinese takeout box icon"
>
<defs>
<linearGradient
x1="67.1763271%"
y1="100%"
x2="34.8280675%"
y2="7.64973958%"
id="linearGradient-1"
>
<stop stopColor="#DADADA" offset="0%"></stop>
<stop stopColor="#FFFFFF" offset="100%"></stop>
</linearGradient>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="linearGradient-2">
<stop stopColor="#EEEEEE" offset="0%"></stop>
<stop stopColor="#D8D8D8" offset="100%"></stop>
</linearGradient>
<linearGradient x1="50%" y1="0%" x2="50%" y2="100%" id="linearGradient-3">
<stop stopColor="#FDFDFD" offset="0%"></stop>
<stop stopColor="#D0D0D0" offset="100%"></stop>
</linearGradient>
<linearGradient
x1="77.7522044%"
y1="41.0699775%"
x2="50%"
y2="69.6638897%"
id="linearGradient-4"
>
<stop stopColor="#F7F7F7" offset="0%"></stop>
<stop stopColor="#EDEDED" offset="88.7869068%"></stop>
<stop stopColor="#ECECEC" offset="100%"></stop>
</linearGradient>
</defs>
<g id="takeout-custom" stroke="none" strokeWidth="1" fill="none" fillRule="evenodd">
<g id="Group-2-Copy" transform="translate(6.000000, 1.000000)">
<polygon
id="Path"
fill="#D8D8D8"
points="0 31 59 49.5 59 127 7.58921162 108.75"
></polygon>
<g id="Group" transform="translate(0.000000, 31.000000)" fillRule="nonzero">
<polygon
id="Path"
fill="#FF0000"
points="23.5020747 8 30.3568465 8 30.3568465 21.5 40.3941909 21.5 40.3941909 35.5 52.879668 35.5 52.879668 63.5 47.9834025 63.5 47.9834025 82 38.9253112 82 38.9253112 78.25 32.8049793 78.25 32.8049793 76.75 23.0124481 76.75 23.0124481 74 14.93361 74 14.93361 70.5 6.12033195 70.5 6.12033195 40 8.56846473 40 8.56846473 17 23.5020747 17"
></polygon>
<path
d="M20.3195021,5.25 L59,18 L59,28 L59,96 L6.12033195,78.25 L0,0 L21.7883817,6.75 L23.5020747,17 C19.7482711,11.8888889 17.8713693,8.47222222 17.8713693,6.75 C17.8713693,5.02777778 18.6874136,4.52777778 20.3195021,5.25 Z M24.4813278,16 C23.8964689,16.484 23.8964689,16.484 23.9591411,17.3625 L23.9610996,17.87775 C23.9610996,20.1975 23.9610996,20.1975 23.2964315,21.13575 C22.7524564,21.59375 22.7524564,21.59375 22.2082365,21.959 L21.7883817,22.25 L21.7883817,22.75 L20.8091286,23.25 L20.3807054,23.53125 C19.5392822,23.8655 18.7700788,23.7805 17.8713693,23.75 L17.6916763,23.23825 C17.1207718,21.50875 17.1207718,21.50875 15.9128631,20.25 C14.9722905,20.273 14.9722905,20.273 14.1991701,20.75 C13.9230207,22.56375 14.7629751,23.98 15.6680498,25.5 C16.4683444,26.5855 17.007668,26.9735 18.3609959,27.25 L18.3609959,32.25 L17.3817427,32.75 L17.0757261,33.15625 C16.3554855,33.73425 15.5808963,33.54475 14.6887967,33.5 L14.4469212,32.97075 L14.1225436,32.28125 L13.8042863,31.59575 C13.4843154,30.934 13.4843154,30.934 12.7302905,30.5 C11.9170207,30.581 11.6021909,30.652 11.0165975,31.25 C11.0165975,33.306 11.907473,34.6525 13.3002158,36.087 C14.2297718,37.09 14.2297718,37.09 15.4232365,37.5 C15.4247054,38.59925 15.4102614,39.69775 15.3926349,40.797 L15.3945934,41.74075 C15.3475892,43.982 15.3475892,43.982 14.8689793,44.74025 C14.2003942,45.34025 13.6855519,45.50325 12.806917,45.625 C11.9958506,45.5 11.9958506,45.5 11.552249,45.21875 C11.2614108,44.75 11.2614108,44.75 11.0320207,44.03125 C10.7414274,43.159 10.4393278,42.64175 9.79253112,42 C9.15944398,41.854 9.15944398,41.854 8.56846473,42 C7.99731535,42.484 7.99731535,42.484 7.95643154,43.34375 C8.00686307,45.11175 8.92246473,46.7845 10.0554606,48.08125 C11.0023983,49.015 11.0023983,49.015 12.2406639,49.25 L12.2406639,63.5 L9.30290456,62.5 L8.32365145,62.5 C8.02448963,63.111 8.02106224,63.5525 8.00221162,64.2345 L7.97454772,64.8975 C8.02693776,65.51575 8.02693776,65.51575 8.44605809,65.90725 C9.29311203,66.38175 10.1793361,66.68975 11.0932241,67 C11.5368257,67.15475 11.9806722,67.30975 12.4242739,67.465 L13.16361,67.72225 C14.4787469,68.18425 15.7887427,68.66125 17.0989834,69.13725 L18.5656598,69.66975 C20.1601286,70.24775 21.7543527,70.8265 23.3480871,71.40625 C27.5046205,72.916936 31.6668032,74.4113636 35.8345436,75.8895 L37.1648589,76.36125 L37.8165519,76.5925 C38.9554232,76.99725 40.0928257,77.40625 41.2275353,77.82325 C41.6848465,77.99125 42.1424025,78.158 42.5999585,78.3245 C43.1307252,78.5180118 43.6605887,78.7140973 44.1895311,78.91275 L44.8830871,79.164 L45.4686805,79.3815 C46.1306556,79.5225 46.6004523,79.425 47.2489627,79.25 C47.2687925,78.8075 47.2832365,78.36475 47.2949876,77.92175 L47.3206929,77.17475 C47.2359876,76.37675 47.1040332,76.24925 46.5145228,75.75 C46.0601494,75.516 46.0601494,75.516 45.5582822,75.34875 L45.006473,75.151 C44.8174772,75.0855 44.6284813,75.02025 44.43361,74.95325 L43.8607469,74.7465 C42.7664315,74.35825 42.7664315,74.35825 41.6182573,74.25 L41.6182573,59.25 L43.8215768,59 C44.8434274,58.47825 45.0382988,58.257 45.596473,57.29675 L45.9827884,56.65125 C46.2924772,55.94825 46.3485394,55.5115 46.2697095,54.75 C46.02,54.26575 46.02,54.26575 45.5352697,54 C44.8113568,53.8125 44.8113568,53.8125 44.06639,54 L43.6225436,54.75 C42.9681577,55.66675 42.9681577,55.66675 42.0924606,55.8125 C41.4116349,55.77775 41.0265436,55.637 40.5273693,55.158 C39.6680747,54.1655 38.9348589,53.08325 38.1908714,52 L37.7012448,51.5 C37.6231494,50.7775 37.6231494,50.7775 37.5998921,49.865 L37.571249,48.879 L37.5482365,47.84375 C37.5135542,46.6459457 37.4829523,45.4480218 37.4564315,44.25 C37.6831286,44.2575 37.9095809,44.2655 38.1431328,44.2735 C39.1936266,44.242 39.6041784,43.9515 40.3941909,43.25 C41.2113776,42.0625 41.5705187,40.96525 41.373444,39.5 C41.1178589,38.98875 41.1178589,38.98875 40.6390041,38.75 C40.0377427,38.5625 40.0377427,38.5625 39.4149378,38.75 C38.6692365,39.6445 38.6692365,39.6445 38.4356846,40.75 C37.5193485,40.82425 37.0713402,40.83375 36.2323651,40.5 C34.625166,39.03375 33.9416473,37.4875 33.7915768,35.323 C33.7606528,34.3823019 33.7582035,33.4408526 33.7842324,32.5 L34.5904025,32.41 C35.8022282,32.19625 36.119751,31.885 36.966805,31 C37.5810415,29.945 37.5330581,28.945 37.4564315,27.75 C36.9961826,27.1755 36.9961826,27.1755 36.2475436,27.17175 C35.5109004,27.1885 35.5109004,27.1885 35.0389004,27.5625 C34.6751079,28.0535 34.6751079,28.0535 34.7634855,29 C33.7773776,29.04275 33.2848133,28.9545 32.5229544,28.291 C31.2540871,26.88375 30.1661369,25.346 29.1366971,23.752 C28.6776722,23.027 28.6776722,23.027 28.2414149,22.498 C26.9975187,20.87 27.1987552,18.72525 27.1742739,16.75 L26.3786307,16.375 L25.931112,16.164 C25.3890954,15.94625 25.3890954,15.94625 24.4813278,16 Z"
id="Shape"
fill="url(#linearGradient-1)"
></path>
<path
d="M14.93361,50.5 C17.9301245,51.3345 17.9301245,51.3345 19.1872407,51.8595 C20.4438672,52.3835 21.7190996,52.79125 23.01661,53.19375 C24.8470788,53.765 26.6667759,54.372 28.4874523,54.97475 C28.9457427,55.12625 29.4045228,55.27725 29.8633029,55.4275 C30.5419253,55.65 31.2195685,55.874 31.8974564,56.09875 L32.4960249,56.29375 C34.681473,57.02225 36.7704647,57.89425 38.9253112,58.75 L39.1701245,73 C37.4564315,72.75 37.4564315,72.75 36.2323651,72.25 L36.2323651,60.75 L33.2946058,60 L33.2946058,71.25 L29.1327801,69.84375 L27.8465311,69.4105 L26.7918755,69.05275 L26.2758091,68.87975 C24.3491286,68.224 22.4709212,67.48 20.5643154,66.75 L20.5643154,55.25 L18.8506224,55 L17.626556,54.75 L17.626556,65.5 C15.4232365,64.75 15.4232365,64.75 14.93361,64.5 L14.93361,50.5 Z"
id="Path"
fill="#FFFFFF"
></path>
<path
d="M18.1161826,38.25 C20.377278,38.59525 22.4878133,39.38575 24.6343361,40.15625 C25.2054855,40.35925 25.7768797,40.5615 26.348029,40.76375 C26.7292033,40.89875 27.1101328,41.034 27.4913071,41.1695 C29.9884025,42.056 32.498473,42.90125 35.0082988,43.75 L35.0082988,52.25 C33.4187261,51.8075 31.8607344,51.36975 30.3054357,50.832 L29.7653776,50.6475 C29.1974108,50.45325 28.629444,50.258 28.061722,50.0625 L26.8836805,49.65875 C26.299556,49.45875 25.7154315,49.25875 25.1313071,49.058 C24.0188755,48.67625 22.9059544,48.29775 21.7913195,47.923 L21.0615311,47.677 C20.6288756,47.5314577 20.1960454,47.3864576 19.7630415,47.242 C18.3881701,46.7775 18.3881701,46.7775 18.1161826,46.5 C18.0952066,45.8905829 18.0872075,45.2807722 18.0921909,44.671 L18.0934149,44.117 C18.094639,43.53125 18.0978216,42.94525 18.1007593,42.3595 C18.1022282,41.963 18.1032075,41.56675 18.1041867,41.1705 C18.1068797,40.197 18.1110415,39.2235 18.1161826,38.25 L18.1161826,38.25 Z"
id="Path"
fill="#FFFFFF"
></path>
<path
d="M21.0539419,28 C22.2868216,28.4175 23.5194564,28.83575 24.7520913,29.25425 L26.0094523,29.6805 C26.612917,29.885 27.2163817,30.09 27.8198465,30.295 L28.3848755,30.486 L28.9193029,30.66775 C29.0730456,30.72025 29.2270332,30.77225 29.3854274,30.826 C29.960249,31.0335 30.525278,31.26875 31.0912863,31.5 L31.0912863,37.5 C29.7908382,37.31025 28.6769378,37.05875 27.4516473,36.63275 L26.9172199,36.45075 C26.5480415,36.3245 26.1788631,36.1975 25.8101743,36.07 C25.2434315,35.874 24.6759544,35.6805 24.1084772,35.4875 C23.7487988,35.3642281 23.3891681,35.2408114 23.0295851,35.11725 L22.5142531,34.94125 C21.3271535,34.529 21.3271535,34.529 21.0539419,34.25 C21.0326432,33.7865 21.0277469,33.3225 21.0299502,32.8585 L21.0323983,32.00625 L21.0385187,31.10925 L21.0419461,30.20925 C21.0448838,29.473 21.0488008,28.7365 21.0539419,28 L21.0539419,28 Z"
id="Path"
fill="#FFFFFF"
></path>
<path
d="M23.2572614,56.75 C24.3819336,56.914 25.259834,57.0555 26.3076349,57.3985 L27.0242033,57.63175 L27.7557054,57.875 L28.5045892,58.11825 C30.3230622,58.71575 30.3230622,58.71575 30.6016598,59 C30.6232033,59.612 30.6298133,60.217 30.6256515,60.829 L30.6244274,61.383 C30.6232033,61.96875 30.6200207,62.55475 30.617083,63.1405 C30.6142342,64.5103413 30.6090931,65.8801766 30.6016598,67.25 C29.510527,67.11375 28.5241743,66.9145 27.4888589,66.538 L26.8090124,66.294 L25.9656307,65.9845 L23.2572614,65 L23.2572614,56.75 Z"
id="Path"
fill="#FF0000"
></path>
<path
d="M23.9917012,30.75 C25.3942635,31.0275 26.5216959,31.483 27.7727921,32.25 C28.6068563,32.7613333 28.6068563,33.5113333 27.7727921,34.5 C26.4347308,34.03 25.2316766,33.485 23.9917012,32.75 L23.9917012,30.75 Z"
id="Path"
fill="#FF0000"
></path>
<path
d="M20.3195021,41.75 C21.4179793,41.882 21.9688091,42.32375 23.0124481,42.75 C22.9620873,43.9661997 22.9620873,44.8828664 23.0124481,45.5 C23.0164902,45.549533 23.0980947,45.6328664 23.2572614,45.75 C20.594917,45.03125 20.594917,45.03125 20.3195021,44.75 C20.3018755,44.245 20.2989378,43.7395 20.3040788,43.23425 L20.3109336,42.3975 L20.3195021,41.75 Z"
id="Path"
fill="#FF0000"
></path>
<path
d="M29.8672199,44.75 L32.560166,45.5 L32.560166,49 C30.1426349,48.28125 30.1426349,48.28125 29.8672199,48 C29.8505923,47.4532588 29.8454501,46.9062164 29.8517967,46.35925 L29.8586515,45.45225 L29.8672199,44.75 Z"
id="Path"
fill="#FF0000"
></path>
<path
d="M25.2157676,43.5 C26.0652697,43.6145 27.0944647,43.72275 27.9087137,44 L27.9087137,47.25 C26.6846473,47 26.1950207,47 24.9709544,46.5 L25.2157676,43.5 Z"
id="Path"
fill="#FF0000"
></path>
</g>
<polygon
id="Path-5"
fill="url(#linearGradient-2)"
points="59 23.129771 69.3943662 12.5496183 88.6619718 6 111.985915 17.3358779 113 30.4351145 111.985915 39"
></polygon>
<polygon
id="Path-4"
fill="url(#linearGradient-3)"
points="59 49.5 117 31 111.2 109.25 59 127"
></polygon>
<polygon
id="Path-3"
fill="#E7E7E7"
transform="translate(33.381967, 18.427325) scale(1, -1) rotate(195.000000) translate(-33.381967, -18.427325) "
points="7.53763115 30.3003987 8.49935595 28.9866404 9.76457151 18.5931112 25.126072 8.04576563 26.4008626 8.04306713 31.5242831 8.03222179 32.2408597 6.78415081 35.069498 6.55425188 36.7173688 7.95880387 43.474551 7.90810925 55.598392 16.4460225 59.226302 28.0671745"
></polygon>
<polygon
id="Path-6"
fill="#DDDDDD"
points="61.8531856 23 17 32.4245283 63.1274238 50 109 32.6792453"
></polygon>
<polygon
id="Path-3"
fill="#FFFFFF"
points="60.7885463 50 59 30.0120482 66.6651982 15.5903614 78.6740088 12.3012048 78.6740088 9.77108434 85.3171806 8.25301205 87.6167401 9.77108434 102.180617 8 113.167401 19.1325301 117 32.7951807"
></polygon>
<polygon
id="Path-2"
fill="url(#linearGradient-4)"
points="0 30.7291667 2.73839662 19.9375 10.7046414 13 21.907173 15.0555556 24.6455696 13.2569444 34.6033755 15.5694444 36.0970464 18.6527778 47.0506329 23.5347222 54.021097 33.2986111 59 50"
></polygon>
</g>
</g>
</svg>
</YStack>
))
@@ -1,194 +0,0 @@
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1'])
type InternalPathOptions = {
fallback?: string
origin?: string
}
type ExternalUrlOptions = {
allowedOrigins: readonly string[]
allowLocalHttp?: boolean
}
type RequestOriginOptions = {
allowedHostnames?: readonly string[]
fallbackOrigin?: string
}
type RequestLike = Pick<Request, 'headers' | 'url'>
const DEFAULT_ALLOWED_REQUEST_HOSTNAMES = ['hanzogui.dev', 'www.hanzogui.dev'] as const
const DEFAULT_PUBLIC_ORIGIN = 'https://hanzogui.dev'
export function safeDecodeURIComponent(value: string) {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
export function getSafeInternalPath(value: unknown, options: InternalPathOptions = {}) {
const fallback = options.fallback ?? '/'
if (typeof value !== 'string') {
return fallback
}
const trimmed = value.trim()
if (
!trimmed ||
CONTROL_CHARS.test(trimmed) ||
trimmed.startsWith('//') ||
trimmed.includes('\\')
) {
return fallback
}
const origin =
options.origin ??
(typeof window === 'undefined' ? 'https://hanzogui.dev' : window.location.origin)
try {
const baseUrl = new URL(origin)
const url = new URL(trimmed, baseUrl)
if (url.origin !== baseUrl.origin) {
return fallback
}
return `${url.pathname}${url.search}${url.hash}`
} catch {
return fallback
}
}
export function navigateToInternalPath(
value: unknown,
options: InternalPathOptions = {}
) {
if (typeof window === 'undefined') {
return
}
window.location.assign(getSafeInternalPath(value, options))
}
export function getSafeExternalUrl(value: unknown, options: ExternalUrlOptions) {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
if (!trimmed || CONTROL_CHARS.test(trimmed)) {
return null
}
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
return null
}
if (url.protocol === 'http:' && !isAllowedLocalHttp(url, options.allowLocalHttp)) {
return null
}
const allowedOrigins = new Set(options.allowedOrigins)
return allowedOrigins.has(url.origin) ? url.toString() : null
}
export function getSafeSupabaseAuthUrl(value: unknown) {
const supabaseOrigin = getSupabaseOrigin()
if (!supabaseOrigin) {
return null
}
return getSafeExternalUrl(value, {
allowedOrigins: [supabaseOrigin],
allowLocalHttp: process.env.NODE_ENV !== 'production',
})
}
export function getSafeRequestOrigin(
request: RequestLike,
options: RequestOriginOptions = {}
) {
const fallbackOrigin = options.fallbackOrigin ?? DEFAULT_PUBLIC_ORIGIN
const allowedHostnames = options.allowedHostnames ?? DEFAULT_ALLOWED_REQUEST_HOSTNAMES
try {
const url = new URL(request.url)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return fallbackOrigin
}
if (!isAllowedRequestHostname(url.hostname, allowedHostnames)) {
return fallbackOrigin
}
const forwardedProtocol = getForwardedProtocol(
request.headers.get('x-forwarded-proto')
)
if (forwardedProtocol) {
url.protocol = `${forwardedProtocol}:`
}
if (url.protocol === 'http:' && !isAllowedLocalHttp(url, true)) {
url.protocol = 'https:'
}
return url.origin
} catch {
return fallbackOrigin
}
}
function getSupabaseOrigin() {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
if (!supabaseUrl) {
return null
}
try {
return new URL(supabaseUrl).origin
} catch {
return null
}
}
function isAllowedLocalHttp(url: URL, allowLocalHttp = false) {
if (!allowLocalHttp) {
return false
}
return LOCAL_HOSTNAMES.has(url.hostname)
}
function isAllowedRequestHostname(hostname: string, allowedHostnames: readonly string[]) {
const normalizedHostname = hostname.toLowerCase()
return (
LOCAL_HOSTNAMES.has(normalizedHostname) ||
allowedHostnames.includes(normalizedHostname)
)
}
function getForwardedProtocol(value: string | null) {
const protocol = value?.split(',')[0]?.trim().toLowerCase()
return protocol === 'http' || protocol === 'https' ? protocol : null
}
@@ -1,26 +0,0 @@
import { Button, type ButtonProps } from '@hanzo/gui'
import { BentoIcon } from '../icons/BentoIcon'
import { Span } from '@hanzo/gui'
export const BentoButton = (props: ButtonProps) => {
return (
<Button
theme="green"
borderColor="$color6"
elevation="$2"
size="$3"
rounded="$10"
hoverStyle={{
z: 100,
}}
{...props}
>
<Button.Text fontFamily="$pixel" fontSize={12}>
<Span $sm={{ display: 'none' }}>Copy-Paste</Span> UI
</Button.Text>
<Button.Icon>
<BentoIcon scale={0.8} />
</Button.Icon>
</Button>
)
}
@@ -1,25 +0,0 @@
import { Button, type ButtonProps } from '@hanzo/gui'
import { AddEvenBrandIcon } from '~/features/icons/AddEvenBrandIcon'
export const ConsultingButton = (props: ButtonProps) => {
return (
<Button
theme="gray"
borderColor="$color6"
elevation="$2"
size="$3"
rounded="$10"
hoverStyle={{
z: 100,
}}
{...props}
>
<Button.Text fontFamily="$pixel" fontSize={12}>
Hire Us
</Button.Text>
<Button.Icon>
<AddEvenBrandIcon scale={1} />
</Button.Icon>
</Button>
)
}
@@ -1,25 +0,0 @@
import { Button, type ButtonProps } from '@hanzo/gui'
import { TakeoutIcon } from '../icons/TakeoutIcon'
export const TakeoutButton = (props: ButtonProps) => {
return (
<Button
theme="red"
borderColor="$color6"
elevation="$2"
size="$3"
rounded="$10"
hoverStyle={{
z: 100,
}}
{...props}
>
<Button.Text fontFamily="$pixel" fontSize={12}>
Starter Kit
</Button.Text>
<Button.Icon>
<TakeoutIcon scale={0.75} />
</Button.Icon>
</Button>
)
}
@@ -1,150 +0,0 @@
import * as React from 'react'
import {
type PopoverProps,
Adapt,
Button,
H2,
H5,
Popover,
Sheet,
Theme,
XStack,
YStack,
} from '@hanzo/gui'
import { sendEvent } from '../../analytics/sendEvent'
import { useSubscriptionModal } from '../purchase/useSubscriptionModal'
import { PromoCards } from './PromoCards'
export const UpgradeToProPopover = (props: PopoverProps) => {
const [open, setOpen] = React.useState(false)
const [state, setState] = React.useState({
via: undefined as 'hover' | 'press' | undefined,
viaAt: Date.now(),
})
const { showAppropriateModal, hasPro } = useSubscriptionModal()
return (
<Popover
disableRTL
offset={20}
hoverable={{
delay: 200,
restMs: 340,
move: false,
}}
open={open}
onOpenChange={(next, via) => {
if (open && state.via === 'press' && via === 'hover') {
return
}
setState({ ...state, via, viaAt: Date.now() })
setOpen(next)
}}
{...props}
>
<Popover.Anchor asChild="except-style">
<XStack
rounded="$10"
px="$3"
cursor="pointer"
height={36}
items="center"
select="none"
borderWidth={1}
borderColor="$black5"
bg="$black1"
shadowColor="$shadow1"
shadowRadius={3}
shadowOffset={{ height: 4, width: 0 }}
hoverStyle={{
borderColor: '$black6',
bg: '$black3',
}}
pressStyle={{
bg: '$black2',
borderColor: '$black2',
}}
onPress={() => {
sendEvent(`Pro: Modal Open`)
showAppropriateModal()
setOpen(false)
}}
>
<H2 fontFamily="$mono" flex={1} fontWeight="600" size="$4" color="$black12">
Pro
</H2>
</XStack>
</Popover.Anchor>
<Adapt platform="touch" when="maxMd">
<Sheet zIndex={100000000} modal dismissOnSnapToBottom transition="medium">
<Sheet.Frame>
<Sheet.ScrollView>
<Adapt.Contents />
</Sheet.ScrollView>
</Sheet.Frame>
<Sheet.Overlay z={100} bg="$shadow4" />
</Sheet>
</Adapt>
<Popover.Content
animatePosition
transition="quick"
bg="$background08"
backdropFilter="blur(40px)"
shadowRadius={50}
shadowColor="$shadow4"
shadowOffset={{ height: 30, width: 0 }}
p={0}
rounded="$6"
borderWidth={0}
z={10000}
enterStyle={{
y: 3,
opacity: 0,
}}
exitStyle={{
y: 5,
opacity: 0,
}}
>
<Popover.Arrow bg="$background08" size="$4" />
<YStack maxH="90vh" p="$3" width={280} overflow="hidden" rounded="$6">
<Popover.ScrollView>
<YStack gap="$2">
{!hasPro && (
<H5
py="$2"
pointerEvents="none"
fontFamily="$mono"
size="$5"
text="center"
>
Gui Pro
</H5>
)}
<PromoCards less />
{!hasPro && (
<Theme name="accent">
<Button
rounded="$10"
my="$3"
onPress={() => {
showAppropriateModal()
setOpen(false)
}}
>
<Button.Text fontFamily="$mono">More info</Button.Text>
</Button>
</Theme>
)}
</YStack>
</Popover.ScrollView>
</YStack>
</Popover.Content>
</Popover>
)
}
@@ -1,32 +0,0 @@
import { Avatar } from '@hanzo/gui'
import { getDefaultAvatarImage } from '~/features/user/getDefaultAvatarImage'
import { useUser } from '~/features/user/useUser'
export const UserAvatar = ({ size = 28 }: { size?: number }) => {
const userSwr = useUser()
// Try userDetails first, then fall back to user metadata from auth
const avatarUrl =
userSwr.data?.userDetails?.avatar_url || userSwr.data?.user?.user_metadata?.avatar_url
return (
<Avatar circular size={size}>
<Avatar.Image
width={size}
height={size}
source={{
width: size,
height: size,
uri:
avatarUrl ||
getDefaultAvatarImage(
userSwr.data?.userDetails?.full_name ||
userSwr.data?.user?.user_metadata?.name ||
userSwr.data?.user?.email ||
'User'
),
}}
/>
</Avatar>
)
}
@@ -1,87 +0,0 @@
import { useState } from 'react'
import { Paragraph, Theme, Tooltip, TooltipGroup, XGroup } from '@hanzo/gui'
import { Link } from '~/components/Link'
import { BentoButton } from '../BentoButton'
import { ConsultingButton } from '../ConsultingButton'
import { TakeoutButton } from '../TakeoutButton'
const tooltipDelay = { open: 0, close: 150 }
export const PromoLinksRow = () => {
const [label, setLabel] = useState('')
return (
<TooltipGroup delay={tooltipDelay}>
<Tooltip scope="promo-tooltip" offset={20} placement="bottom">
<XGroup position="absolute" self="center" y={-80} rounded="$8" elevation="$2">
<Tooltip.Trigger
scope="promo-tooltip"
asChild
onMouseEnter={() => setLabel('Takeout — universal RN starter kit')}
>
<Link
hoverStyle={{
z: 100,
}}
href="/takeout"
>
<XGroup.Item>
<TakeoutButton mr={-1} elevation={0} />
</XGroup.Item>
</Link>
</Tooltip.Trigger>
<Tooltip.Trigger
scope="promo-tooltip"
asChild
onMouseEnter={() => setLabel('Bento — Free + paid pre-made UI')}
>
<Link
hoverStyle={{
z: 100,
}}
href="/bento"
>
<XGroup.Item>
<BentoButton mr={-1} elevation={0} />
</XGroup.Item>
</Link>
</Tooltip.Trigger>
<Tooltip.Trigger
scope="promo-tooltip"
asChild
onMouseEnter={() => setLabel('Add Even — Expert React Native developers')}
>
<Link
hoverStyle={{
z: 100,
}}
href="https://addeven.com"
target="_blank"
>
<XGroup.Item>
<ConsultingButton elevation={0} />
</XGroup.Item>
</Link>
</Tooltip.Trigger>
</XGroup>
<Tooltip.Content
animatePosition
transition="medium"
bg="$background"
elevation="$2"
rounded="$4"
px="$2.5"
py="$1"
enterStyle={{ y: -4, opacity: 0 }}
exitStyle={{ y: -4, opacity: 0 }}
>
<Tooltip.Arrow />
<Paragraph size="$3">{label}</Paragraph>
</Tooltip.Content>
</Tooltip>
</TooltipGroup>
)
}
@@ -1,8 +0,0 @@
import { createStore, createUseStore } from '@hanzogui/use-store'
class AccountModal {
show = false
}
export const accountModal = createStore(AccountModal)
export const useAccountModal = createUseStore(AccountModal)
@@ -1,100 +0,0 @@
/**
* promo configuration system
*
* this provides a structured way to define promotional offers that:
* - show a badge on the takeout page
* - auto-apply coupons when opening purchase modal
* - display discounted prices throughout the purchase flow
*/
export type PromoConfig = {
// unique identifier for this promo
id: string
// the stripe promotion code (what user enters)
code: string
// the stripe coupon id (returned from validation)
couponId: string
// display info
label: string
description: string
// discount info for display before validation
percentOff?: number
amountOff?: number
// whether this promo is currently active
active: boolean
// optional: when this promo expires (for countdown etc)
expiresAt?: Date
// optional: limit number of redemptions
maxRedemptions?: number
// theme color for badge
theme?: 'green' | 'yellow' | 'blue' | 'red'
}
export type PromoCoupon = {
id: string
code: string
percent_off: number | null
amount_off: number | null
}
// define active promotions here
export const ACTIVE_PROMOS: PromoConfig[] = [
{
id: 'beta-discount',
code: 'BETA20',
couponId: '5gXstiW7',
label: '20% off',
description: 'beta discount',
percentOff: 20,
active: true,
theme: 'green',
},
]
// get the currently active promo (first active one)
export function getActivePromo(): PromoConfig | null {
const now = new Date()
return (
ACTIVE_PROMOS.find((promo) => {
if (!promo.active) return false
if (promo.expiresAt && promo.expiresAt < now) return false
return true
}) || null
)
}
// get a promo by code
export function getPromoByCode(code: string): PromoConfig | null {
return ACTIVE_PROMOS.find((p) => p.code.toLowerCase() === code.toLowerCase()) || null
}
export function getActivePromoCoupon(): PromoCoupon | null {
const promo = getActivePromo()
if (!promo) return null
return {
id: promo.couponId,
code: promo.code,
percent_off: promo.percentOff ?? null,
amount_off: promo.amountOff ?? null,
}
}
export function getCheckoutCouponId(requestedCouponId?: string | null) {
return requestedCouponId || getActivePromo()?.couponId || null
}
// calculate discounted price
export function calculatePromoPrice(
originalPrice: number,
promo: PromoConfig | null
): number {
if (!promo) return originalPrice
if (promo.percentOff) {
return Math.ceil(originalPrice * (1 - promo.percentOff / 100))
}
if (promo.amountOff) {
return Math.max(0, originalPrice - promo.amountOff)
}
return originalPrice
}
@@ -1,24 +0,0 @@
import { createStore, createUseStore } from '@hanzogui/use-store'
import type { PromoConfig } from './promoConfig'
class PurchaseModal {
show = false
yearlyTotal = 0
monthlyTotal = 0
disableAutoRenew = false
chatSupport = false
supportTier = 0
teamSeats = 0
selectedPrices = {
disableAutoRenew: false,
chatSupport: false,
supportTier: 0,
teamSeats: 0,
}
// promo support
activePromo: PromoConfig | null = null
prefilledCouponCode: string | null = null
}
export const purchaseModal = createStore(PurchaseModal)
export const usePurchaseModal = createUseStore(PurchaseModal)
@@ -1,66 +0,0 @@
import { useUser } from '~/features/user/useUser'
import { accountModal } from './accountModalStore'
import { getActivePromo } from './promoConfig'
import { purchaseModal } from './purchaseModalStore'
/**
* Check if the URL has the test purchase query param
* Only works for developers in the whitelist
*/
const isTestPurchaseMode = (): boolean => {
if (typeof window === 'undefined') return false
const params = new URLSearchParams(window.location.search)
return params.get('testPurchase') === 'true'
}
/**
* This hook is used to show the appropriate modal based on the user's subscription status.
* - If the user is not logged in, it shows the purchase modal.
* - If the user is logged in and does not have an active subscription, it shows the purchase modal.
* - If the user is logged in and has an active subscription, it shows the account modal.
* - If the user is a developer with ?testPurchase=true, it shows the purchase modal.
*
* automatically applies any active promo from promoConfig
*/
export const useSubscriptionModal = () => {
const { data: userData, isLoading, subscriptionStatus } = useUser()
// use server-side computed hasPro which includes subscriptions, legacy access, whitelist, and team access
const hasPro = userData?.accessInfo?.hasPro || subscriptionStatus.pro
const showAppropriateModal = () => {
if (isLoading) return
// developers can bypass pro check with ?testPurchase=true
const devTestMode = subscriptionStatus.isDeveloper && isTestPurchaseMode()
if (hasPro && !devTestMode) {
accountModal.show = true
} else {
// always apply active promo if one exists
const activePromo = getActivePromo()
if (activePromo) {
purchaseModal.activePromo = activePromo
purchaseModal.prefilledCouponCode = activePromo.code
} else {
purchaseModal.activePromo = null
purchaseModal.prefilledCouponCode = null
}
// for dev test mode, prefill the test coupon
if (devTestMode) {
purchaseModal.prefilledCouponCode = 'DEV_TEST_99'
}
purchaseModal.show = true
}
}
return {
showAppropriateModal,
isLoading,
userData,
subscriptionStatus,
hasPro,
}
}
@@ -1,178 +0,0 @@
const isTestMode =
process.env.STRIPE_TEST_MODE === 'true' || process.env.NODE_ENV === 'development'
// Test mode products (for local development)
const TEST_PRODUCTS = {
PRO_V2_LICENSE: {
productId: 'prod_TzXqrfOfIkbBHW',
priceId: 'price_1T4Os6FQGtHoG6xcZdBRTpwo', // $250 one-time
},
PRO_V2_UPGRADE: {
productId: 'prod_TzXqrfOfIkbBHW',
priceId: 'price_1T1YlZFQGtHoG6xcQrmlViBL', // $100/year
},
SUPPORT_DIRECT: {
productId: 'prod_TzXrJxfpINP8A4',
priceId: 'price_1T1YlsFQGtHoG6xctR0JNMhw', // $500/month
},
SUPPORT_SPONSOR: {
productId: 'prod_TzXre5xy4fsDlI',
priceId: 'price_1T1YlvFQGtHoG6xcjpn8rd30', // $2000/month
},
// V1 products not needed in test mode (legacy)
PRO_SUBSCRIPTION: { productId: '', priceId: '' },
PRO_ONE_TIME: { productId: '', priceId: '' },
PRO_TEAM_SEATS: { productId: '', priceId: '' },
PRO_TEAM_SEATS_ONE_TIME: { productId: '', priceId: '' },
SUPPORT: { productId: '', priceId: '' },
CHAT: { productId: '', priceId: '' },
}
// Live mode products (production)
const LIVE_PRODUCTS = {
// ============================================
// V2 PRODUCTS (per-project licensing)
// ============================================
/**
* V2 Pro License - $250 one-time per project
* Includes: all templates (v1 takeout, v2 takeout, takeout static)
* Includes: 1 year of updates, basic chat support, unlimited team
*/
PRO_V2_LICENSE: {
productId: 'prod_TneqayKPO32G63',
priceId: 'price_1T4OsOFQGtHoG6xcBAL0yFd1', // $250 one-time
},
/**
* V2 Pro Upgrade - $100/year recurring
* Auto-subscribed on purchase, starts after 1 year
* Continues access to updates
*/
PRO_V2_UPGRADE: {
productId: 'prod_TneqayKPO32G63',
priceId: 'price_1Sv5TTFQGtHoG6xcm8GJ3Uhg',
},
// ============================================
// V1 PRODUCTS (legacy, per-seat licensing)
// ============================================
/**
* @deprecated V1 - $240/year with auto-renew
*/
PRO_SUBSCRIPTION: {
productId: 'prod_RlRd2DVrG0frHe',
priceId: 'price_1QthHSFQGtHoG6xcDOEuFsrW',
},
/**
* @deprecated V1 - $400 one-time payment
*/
PRO_ONE_TIME: {
productId: 'prod_RlRd2DVrG0frHe',
priceId: 'price_1Qs41HFQGtHoG6xcerDq7RJZ',
},
/**
* @deprecated V1 - Team seats
*/
PRO_TEAM_SEATS: {
productId: 'prod_Rxu0x7jR0nWJSv',
priceId: 'price_1R3yCAFQGtHoG6xcatVUMGL4',
},
/**
* @deprecated V1 - Team seats one-time
*/
PRO_TEAM_SEATS_ONE_TIME: {
productId: 'prod_Rxu0x7jR0nWJSv',
priceId: 'price_1R3yCaFQGtHoG6xcwQ8EtfDu',
},
// ============================================
// V2 SUPPORT PRODUCTS
// ============================================
/**
* V2 Direct Support - $500/month
* 5 bug fixes/year, 2 business day response, prioritized issues
*/
SUPPORT_DIRECT: {
productId: 'prod_TsDjQ6tmdFy7M6',
priceId: 'price_1SuTIHFQGtHoG6xcSRnWg3xB',
},
/**
* V2 Sponsor Support - $2,000/month
* Unlimited priority fixes, 1 day response, monthly video call
*/
SUPPORT_SPONSOR: {
productId: 'prod_TsDjG5QpL21tT1',
priceId: 'price_1SuTIVFQGtHoG6xcVVnbEeQx',
},
// ============================================
// V1 SUPPORT PRODUCTS (legacy)
// ============================================
/**
* @deprecated V1 - Premium Support - $800/month per tier
*/
SUPPORT: {
productId: 'prod_RlRebXO307MLoH',
priceId: 'price_1QrulKFQGtHoG6xcDs9OYTFu',
},
/**
* @deprecated V1 only - Chat support $200/month
* Note: Basic chat support included free in V2
*/
CHAT: {
productId: 'prod_RlRdUMAas8elvJ',
priceId: 'price_1QrukQFQGtHoG6xcMpB125IR',
},
}
export const STRIPE_PRODUCTS = isTestMode ? TEST_PRODUCTS : LIVE_PRODUCTS
// Product IDs (used for checking subscription status)
export const STRIPE_PRODUCTS_ENUM = {
// V2
PRO_V2_LICENSE: STRIPE_PRODUCTS.PRO_V2_LICENSE.productId,
SUPPORT_DIRECT: STRIPE_PRODUCTS.SUPPORT_DIRECT.productId,
SUPPORT_SPONSOR: STRIPE_PRODUCTS.SUPPORT_SPONSOR.productId,
// V1 (legacy)
PRO_SUBSCRIPTION: STRIPE_PRODUCTS.PRO_SUBSCRIPTION.productId,
PRO_TEAM_SEATS: STRIPE_PRODUCTS.PRO_TEAM_SEATS.productId,
SUPPORT: STRIPE_PRODUCTS.SUPPORT.productId,
CHAT: STRIPE_PRODUCTS.CHAT.productId,
} as const
// V2 products for new purchases
export const V2_PRODUCTS = [
STRIPE_PRODUCTS_ENUM.PRO_V2_LICENSE,
STRIPE_PRODUCTS_ENUM.SUPPORT_DIRECT,
STRIPE_PRODUCTS_ENUM.SUPPORT_SPONSOR,
]
// V1 products (legacy, for existing subscribers)
export const V1_PRODUCTS = [
STRIPE_PRODUCTS_ENUM.PRO_SUBSCRIPTION,
STRIPE_PRODUCTS_ENUM.PRO_TEAM_SEATS,
STRIPE_PRODUCTS_ENUM.SUPPORT,
STRIPE_PRODUCTS_ENUM.CHAT,
]
// All current products
export const CURRENT_PRODUCTS = [
// V2 products
STRIPE_PRODUCTS_ENUM.PRO_V2_LICENSE,
STRIPE_PRODUCTS_ENUM.SUPPORT_DIRECT,
STRIPE_PRODUCTS_ENUM.SUPPORT_SPONSOR,
// V1 products (legacy)
STRIPE_PRODUCTS_ENUM.PRO_SUBSCRIPTION,
STRIPE_PRODUCTS_ENUM.PRO_TEAM_SEATS,
STRIPE_PRODUCTS_ENUM.SUPPORT,
STRIPE_PRODUCTS_ENUM.CHAT,
]
@@ -1,20 +0,0 @@
import Stripe from 'stripe'
import { serverEnv } from '../api/serverEnv'
const isTestMode =
process.env.STRIPE_TEST_MODE === 'true' || process.env.NODE_ENV === 'development'
const secretKey = isTestMode
? serverEnv('STRIPE_SECRET_KEY_TEST')
: serverEnv('STRIPE_SECRET_KEY')
export const stripe = new Stripe(secretKey!, {
// https://github.com/stripe/stripe-node#configuration
apiVersion: '2020-08-27',
// Register this as an official Stripe plugin.
// https://stripe.com/docs/building-plugins#setappinfo
appInfo: {
name: 'Next.js Subscription Starter',
version: '0.1.0',
},
})
@@ -1,55 +0,0 @@
export const sponsorshipDateMap = {
NOT_SPONSOR: {
tierName: null,
dateStr: 'late October 2023',
date: new Date(2023, 10, 30),
},
ST_kwDNL0TOAAMT2w: {
tierName: '$10 a month',
dateStr: 'late September 2023',
date: new Date(2023, 9, 30),
},
ST_kwDNL0TOAANQFA: {
tierName: '$100 a month',
dateStr: 'late May 2023',
date: new Date(2023, 4, 30),
},
ST_kwDNL0TOAAPNDw: {
tierName: '$200 a month',
dateStr: 'May 2023',
date: new Date(2023, 4, 1),
},
ST_kwDNL0TOAAPNEQ: {
tierName: '$500 a month',
dateStr: 'April 2023',
date: new Date(2023, 3, 1),
},
ST_kwDNL0TOAAPNEg: {
tierName: '$1,000 a month',
dateStr: 'April 2023',
date: new Date(2023, 3, 1),
},
/*
N/A:
$50 one time "ST_kwDNL0TOAAMTSA"
$100 one time "ST_kwDNL0TOAAMT2g"
$400 one time "ST_kwDNL0TOAAMTSg"
*/
}
/**
* smaller index = highest priority
*/
export const tiersPriority: (keyof typeof sponsorshipDateMap)[] = [
'ST_kwDNL0TOAAPNEg',
'ST_kwDNL0TOAAPNEQ',
'ST_kwDNL0TOAAPNDw',
'ST_kwDNL0TOAANQFA',
'ST_kwDNL0TOAAMT2w',
'NOT_SPONSOR',
]
@@ -1,71 +0,0 @@
import type Stripe from 'stripe'
export interface PageMeta {
title: string
description: string
cardImage: string
}
export interface Customer {
id: string /* primary key */
stripe_customer_id?: string
}
export interface Product {
id: string /* primary key */
active?: boolean
name?: string
description?: string
image?: string
metadata?: Stripe.Metadata
}
export interface ProductWithPrice extends Product {
prices?: Price[]
}
export interface UserDetails {
id: string /* primary key */
first_name: string
last_name: string
full_name?: string
avatar_url?: string
billing_address?: Stripe.Address
payment_method?: Stripe.PaymentMethod[Stripe.PaymentMethod.Type]
}
export interface Price {
id: string /* primary key */
product_id?: string /* foreign key to products.id */
active?: boolean
description?: string
unit_amount?: number
currency?: string
type?: string
interval?: Stripe.Price.Recurring.Interval
interval_count?: number
trial_period_days?: number | null
metadata?: Stripe.Metadata
products?: Product
}
export interface PriceWithProduct extends Price {}
export interface Subscription {
id: string /* primary key */
user_id: string
status?: Stripe.Subscription.Status
metadata?: Stripe.Metadata
price_id?: string /* foreign key to prices.id */
quantity?: number
cancel_at_period_end?: boolean
created: string
current_period_start: string
current_period_end: string
ended_at?: string
cancel_at?: string
canceled_at?: string
trial_start?: string
trial_end?: string
prices?: Price
}
@@ -1,424 +0,0 @@
import slugify from '@sindresorhus/slugify'
import { Input } from '@hanzogui/input'
import { History, Moon, Plus, Sun, X } from '@hanzogui/lucide-icons-2'
import { animations } from '@hanzogui/dev-config'
import { useStore } from '@hanzogui/use-store'
import { useUserScheme } from '@vxrn/color-scheme'
import { router } from 'one'
import { memo, useEffect, useLayoutEffect, useOptimistic, useRef, useState } from 'react'
import useSWR, { mutate } from 'swr'
import {
Button,
Configuration,
Paragraph,
ScrollView,
Spinner,
Switch,
Theme,
useThemeName,
XStack,
YStack,
} from '@hanzo/gui'
import { authFetch } from '../../api/authFetch'
import { getAccessToken } from '../../auth/useSupabaseClient'
import { defaultModel } from '../../api/generateModels'
import { getActivePromo } from '../../site/purchase/promoConfig'
import { purchaseModal } from '../../site/purchase/purchaseModalStore'
import { useUser } from '../../user/useUser'
import { toastController } from '../ToastProvider'
import { themeJSONToText } from './helpers/themeJSONToText'
import { RandomizeButton } from './RandomizeButton'
import { type UpdateGenerateArgs, useThemeBuilderStore } from './store/ThemeBuilderStore'
import { ThemePageStore } from './themePageStore'
import { Link } from '../../../components/Link'
type ArgumentTypes<F extends Function> = F extends (...args: infer A) => any ? A : never
interface StudioAIBarProps {
initialTheme?: {
themeSuite: UpdateGenerateArgs[0]
query?: UpdateGenerateArgs[1]
themeId?: UpdateGenerateArgs[2]
username?: UpdateGenerateArgs[3]
}
}
export const StudioAIBar = memo(({ initialTheme }: StudioAIBarProps) => {
const model = defaultModel
const inputRef = useRef<HTMLInputElement>(null)
const user = useUser()
const themePage = useStore(ThemePageStore)
const themeBuilderStore = useThemeBuilderStore()
const [isGenerating, setGenerating] = useState<'reply' | 'new' | 'delete' | null>(null)
const themeName = useThemeName()
const [lastPrompt, setLastPrompt] = useState('')
const id = themePage.curProps?.id || initialTheme?.themeId || 0
const [active, setActive] = useState(id)
useEffect(() => {
setActive(themePage.curProps?.id)
}, [themePage.curProps?.id])
useEffect(() => {
inputRef.current?.focus()
}, [id])
const hasAccess = user.data?.accessInfo.hasPro
const username = user.data?.userDetails?.full_name
const { data: historiesData } = useSWR(
user.data ? '/api/theme/histories' : null,
async (url) => {
const res = await authFetch(url)
const data = await res.json()
return data.histories.map((history) => ({
themeSuite: history.theme_data,
query: history.search_query,
themeId: history.id,
username: username,
})) as NonNullable<StudioAIBarProps['initialTheme']>[]
}
)
useLayoutEffect(() => {
if (initialTheme) {
inputRef.current!.value = initialTheme.query ?? ''
themeBuilderStore.updateGenerate(
initialTheme.themeSuite,
initialTheme.query,
initialTheme.themeId,
initialTheme.username
)
}
}, [initialTheme?.themeSuite?.name])
const themeSuite = themeBuilderStore.themeSuite
const lastReply = id && themeSuite ? themeJSONToText(themeSuite) : ''
const fetchUpdate = async (
type: 'reply' | 'new' | 'delete',
themeIdToDelete?: string
) => {
if (type !== 'delete' && !inputRef.current?.value.trim()) {
toastController.show(`Please enter a prompt`)
return
}
if (type !== 'delete') {
toastController.show(`Generating...`)
} else {
toastController.show(`Deleting theme...`)
}
setGenerating(type as 'reply' | 'new')
let seconds = 0
const int = setInterval(() => {
seconds++
if (seconds === 4) {
toastController.show(
`${type === 'delete' ? 'Still deleting...' : 'Thinking about colors...'}`
)
} else if (seconds === 8) {
toastController.show(`...`)
} else if (seconds === 12) {
toastController.show(
`${type === 'delete' ? 'Almost done...' : 'Refining palettes...'}`
)
} else if (seconds === 16) {
toastController.show(`Taking too long...`)
} else if (seconds === 24) {
toastController.show(`It really does take a bit sometimes...`)
}
}, 1000)
try {
let prompt = inputRef.current?.value ?? ''
const lastId = `${type === 'delete' ? themeIdToDelete : id}`
const accessToken = await getAccessToken()
const res = await fetch(`/api/theme/generate`, {
body: JSON.stringify({
prompt,
model,
lastReply,
lastId,
lastPrompt,
scheme: themeName.startsWith('dark') ? 'dark' : 'light',
action: type === 'delete' ? 'delete' : 'generate',
}),
headers: {
'Content-Type': 'application/json',
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
},
method: 'POST',
})
const data = await res.json()
console.info(`got themes`, data)
if (data.error) {
toastController.show(
`Error ${type === 'delete' ? 'deleting' : 'generating'}! ${data.error}`
)
return
}
if (type !== 'delete') {
if (!lastId) {
// created new one just go there
router.navigate(`/theme/${data.themeId}/${data.slug}`)
} else {
await themeBuilderStore.updateGenerate(
data.result,
slugify(prompt),
data.themeId,
username
)
}
} else {
toastController.show('Theme deleted')
}
await mutate('/api/theme/histories')
if (type !== 'delete') {
setLastPrompt(prompt)
}
toastController.hide()
} catch (err) {
toastController.show(`Error: ${err}`)
} finally {
setGenerating(null)
clearInterval(int)
}
}
return (
<XStack
z={1000}
data-tauri-drag-region
className="all ease-in ms300"
$lg={{ mr: '$6' }}
>
<YStack flex={1} flexBasis="auto" width="100%" gap="$4">
<XStack flexWrap="wrap" items="center" flex={1} flexBasis="auto" gap="$3">
<XStack minW={300} flex={1} flexBasis="auto" position="relative">
<Input
ref={inputRef as any}
flex={1}
placeholder={active ? `Refine this theme` : `Generate a theme`}
size="$6"
shadowColor="$shadow3"
bg="$color1"
shadowOffset={{ height: 2, width: 0 }}
shadowRadius={10}
rounded="$8"
onSubmit={() => {
fetchUpdate(active ? 'reply' : 'new')
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
fetchUpdate('new')
}
}}
/>
</XStack>
<XStack gap="$3" items="center" justify="space-between">
<Theme name="accent">
<Button
rounded="$10"
disabled={isGenerating === 'new'}
opacity={isGenerating === 'new' ? 0.2 : 1}
pointerEvents={isGenerating === 'new' ? 'none' : 'auto'}
icon={isGenerating === 'new' ? <Spinner size="small" /> : null}
onPress={() => {
if (hasAccess) {
fetchUpdate('new')
} else {
const activePromo = getActivePromo()
if (activePromo) {
purchaseModal.activePromo = activePromo
purchaseModal.prefilledCouponCode = activePromo.code
}
purchaseModal.show = true
}
}}
size="$4"
>
{hasAccess ? (active ? 'Refine' : 'Generate') : 'Access'}
</Button>
<RandomizeButton />
</Theme>
<ThemeToggle />
</XStack>
</XStack>
<ScrollView
mx="$-6"
px="$6"
flex={1}
flexBasis="auto"
horizontal
showsHorizontalScrollIndicator={false}
>
<XStack gap="$2" py="$2">
<Link href="/theme">
<HistoryButton icon={<Plus size={14} />}>New</HistoryButton>
</Link>
{(historiesData || []).map((history) => {
if (!history.themeId) {
return null
}
return (
<XStack key={history.query}>
<Link
delayNavigate
href={`/theme/${history.themeId!}/${slugify(history.query!)}`}
>
<HistoryButton
icon={<History size={14} />}
active={active === history.themeId}
onDelete={() => {
if (confirm('Are you sure you want to delete this theme?')) {
fetchUpdate('delete', `${history.themeId || ''}`)
router.navigate('/theme')
}
}}
onPress={() => {
console.warn('set active')
setActive(+history.themeId!)
}}
>
{history.query}
</HistoryButton>
</Link>
</XStack>
)
})}
{!hasAccess && (
<XStack flex={1} flexBasis="auto" overflow="hidden" items="center" px="$4">
<Paragraph fontFamily="$mono" size="$3">
Welcome to the Theme Builder! Pro members can build, save and refine
themes using the generate input above.
</Paragraph>
</XStack>
)}
</XStack>
</ScrollView>
</YStack>
</XStack>
)
})
const HistoryButton = ({
active,
children,
icon,
onDelete,
onPress,
}: {
active?: boolean
children?: any
icon?: any
onDelete?: () => void
onPress?: () => void
}) => {
return (
<XStack group="item" containerType="normal" position="relative">
<Button onPress={onPress} size="$3" rounded="$8" theme={active ? 'accent' : null}>
<Button.Icon>{icon}</Button.Icon>
<Button.Text numberOfLines={1} maxW={200} fontFamily="$mono">
{children}
</Button.Text>
</Button>
{onDelete && (
<YStack
position="absolute"
opacity={0}
$group-item-hover={{
opacity: 1,
}}
t={-5}
r={-5}
onPress={(e) => {
e.preventDefault()
e.stopPropagation()
onDelete()
}}
>
<X opacity={0.3} size={10} />
</YStack>
)}
</XStack>
)
}
const ThemeToggle = () => {
const userScheme = useUserScheme()
const [checked, setChecked] = useState(userScheme.value === 'light')
useEffect(() => {
setChecked(userScheme.value === 'light')
}, [userScheme.value === 'light'])
return (
<XStack gap="$3" items="center">
<Configuration animationDriver={animations.css}>
<Switch
checked={checked}
outlineWidth={0}
outlineStyle="solid"
activeStyle={{
backgroundColor: '$accent10',
}}
onCheckedChange={(on) => {
setChecked(on)
setTimeout(() => {
userScheme.set(on ? 'light' : 'dark')
})
}}
size="$3"
>
<Switch.Thumb transition="quickest" size="$3">
<YStack
transition="bouncy"
fullscreen
items="center"
justify="center"
opacity={1}
y={0}
$theme-light={{ opacity: 0, y: 3 }}
>
<Moon size={14} />
</YStack>
<YStack
transition="bouncy"
fullscreen
items="center"
justify="center"
opacity={1}
y={0}
$theme-dark={{ opacity: 0, y: 3 }}
>
<Sun size={14} />
</YStack>
</Switch.Thumb>
</Switch>
</Configuration>
</XStack>
)
}
@@ -1,89 +0,0 @@
import z from 'zod'
import { supabaseAdmin } from '../../auth/supabaseAdmin'
import type { ThemeSuiteItemData } from './types'
const ColorEntrySchema = z.object({
index: z.number(),
hue: z.object({
light: z.number(),
dark: z.number(),
sync: z.boolean().optional(),
syncLeft: z.boolean().optional(),
}),
sat: z.object({
light: z.number(),
dark: z.number(),
sync: z.boolean().optional(),
syncLeft: z.boolean().optional(),
}),
lum: z.object({
light: z.number(),
dark: z.number(),
sync: z.boolean().optional(),
syncLeft: z.boolean().optional(),
}),
})
const PaletteSchema = z.object({
name: z.string(),
anchors: z.array(ColorEntrySchema),
})
export const ThemeSuiteSchema = z.object({
name: z.string(),
palettes: z.object({
base: PaletteSchema,
accent: PaletteSchema,
}),
schemes: z.object({
light: z.boolean().optional(),
dark: z.boolean().optional(),
}),
templateStrategy: z.literal('base'),
})
export const getTheme = async (id: string, _request?: Request) => {
// read via the service role: theme_histories is locked down (no public read);
// public sharing of a specific theme by id is mediated here server-side.
const { data: currentTheme, error: themeError } = await supabaseAdmin
.from('theme_histories')
.select(`
theme_data,
search_query,
id,
is_cached,
og_image_url,
user_id
`)
.eq('id', Number.parseInt(id))
.single()
let user_name: string | null = null
if (currentTheme?.user_id) {
// read the author's name via the service role: users RLS only exposes the
// caller's own row, and this displays a different user's public theme.
const { data: user } = await supabaseAdmin
.from('users')
.select('full_name')
.eq('id', currentTheme.user_id)
.single()
user_name = user?.full_name ?? null
}
if (themeError) {
return null
}
const themeData = ThemeSuiteSchema.parse(currentTheme.theme_data)
return {
theme: themeData as unknown as ThemeSuiteItemData,
search: currentTheme.search_query,
id: currentTheme.id,
is_cached: currentTheme.is_cached as boolean | null,
og_image_url: currentTheme.og_image_url as string | null,
user_name: user_name as string | null,
}
}
@@ -1,18 +0,0 @@
import type { ThemeSuiteItemData } from '../types'
export function themeJSONToText(theme: ThemeSuiteItemData): string {
const baseAnchors = theme.palettes.base.anchors
const accentAnchors = theme.palettes.accent.anchors
const baseText = formatAnchors(baseAnchors)
const accentText = formatAnchors(accentAnchors)
return `${baseText}\n\n${accentText}`
}
function formatAnchors(anchors: any[]): string {
return anchors
.map((anchor) => {
const { index, hue, sat, lum } = anchor
return `${index}: ${hue.light},${hue.dark} ${sat.light},${sat.dark} ${lum.light},${lum.dark}`
})
.join('\n')
}
File diff suppressed because it is too large Load Diff
@@ -1,5 +0,0 @@
export const getDefaultAvatarImage = (name: string) => {
const params = new URLSearchParams()
params.append('name', name)
return `https://ui-avatars.com/api/?${params.toString()}`
}
-439
View File
@@ -1,439 +0,0 @@
import type { User } from '@supabase/supabase-js'
import {
whitelistBentoUsernames,
whitelistGithubUsernames,
} from '~/features/github/helpers'
import { getArray } from '~/helpers/getArray'
import { getSingle } from '~/helpers/getSingle'
import { ProductName, ProductSlug, SubscriptionStatus } from '~/shared/types/subscription'
import { supabaseAdmin } from '../auth/supabaseAdmin'
import { hasProAccess } from '../bento/hasProAccess'
import { tiersPriority } from '../stripe/tiers'
import { ThemeSuiteSchema } from '../studio/theme/getTheme'
import type { ThemeSuiteItemData } from '../studio/theme/types'
export const getUserDetails = async (userId: string) => {
const result = await supabaseAdmin
.from('users')
.select('*')
.eq('id', userId)
.maybeSingle()
if (result.error) {
throw new Error(result.error.message)
}
return result.data
}
export const getUserPrivateInfo = async (userId: string) => {
const result = await supabaseAdmin
.from('users_private')
.select('*')
.eq('id', userId)
.limit(1)
if (result.error) {
throw new Error(`Error getting user private info: ${result.error.message}`)
}
return result.data?.[0] || {}
}
export const getUserTeams = async (userId: string) => {
// replicate RLS: only return teams where user has a membership
const { data: memberships } = await supabaseAdmin
.from('memberships')
.select('team_id')
.eq('user_id', userId)
const teamIds = (memberships || []).map((m) => m.team_id)
if (teamIds.length === 0) return []
const result = await supabaseAdmin.from('teams').select('*').in('id', teamIds)
if (result.error) throw new Error(result.error.message)
return result.data
}
export const getActiveSubscriptions = async (
userId?: string,
subscriptionId?: string
) => {
const subscriptions = await getSubscriptions(userId)
return subscriptions.find(
(s) =>
s.id &&
s.id === subscriptionId &&
(s.status === SubscriptionStatus.Active || s.status === SubscriptionStatus.Trialing)
)
}
export const getAllActiveSubscriptions = async (userId: string) => {
const result = await supabaseAdmin
.from('subscriptions')
.select(`
*,
subscription_items (
*,
price:prices (
*,
product:products (*)
)
)
`)
.eq('user_id', userId)
.in('status', ['active', 'trialing'])
if (result.error) {
throw new Error(result.error.message)
}
return result.data
}
export const getSubscriptions = async (uuid?: string) => {
let userId = uuid
if (!userId) {
return []
}
//NOTE: check user is a team member
const { data: teamMember } = await supabaseAdmin
.from('team_members')
.select(`team_subscriptions (owner_id)`)
.eq('member_id', userId)
.eq('status', 'active')
.single()
// NOTE: if the user is a team member, we need to get the owner's id ===> to get the subscription
const ownerId = teamMember?.team_subscriptions?.owner_id
if (ownerId) userId = ownerId
const select = '*, subscription_items(*, prices(*, products(*)), app_installations(*))'
const result = await supabaseAdmin
.from('subscriptions')
.select(select)
.eq('user_id', userId)
if (result.error) {
throw new Error(result.error.message)
}
return result.data.map((sub) => ({
...sub,
subscription_items: getArray(sub.subscription_items).map(({ prices, ...item }) => {
const price = getSingle(prices)
return {
...item,
price: { ...price, product: getSingle(price?.products) },
}
}),
}))
}
export const getOwnedProducts = async (userId: string) => {
const result = await supabaseAdmin
.from('product_ownership')
.select('*, prices(*, products(*))')
.eq('user_id', userId)
if (result.error) {
throw new Error(result.error.message)
}
return result.data.map(({ prices, ...productOwnership }) => {
const price = getSingle(prices)
return {
...productOwnership,
price: { ...price, product: getSingle(price?.products) },
}
})
}
export function getPersonalTeam(
teams: Awaited<ReturnType<typeof getUserTeams>>,
userId: string
) {
return getSingle(teams?.filter((team) => team.is_personal && team.owner_id === userId))
}
export function getOrgTeams(teams: Awaited<ReturnType<typeof getUserTeams>>) {
return getArray(teams?.filter((team) => !team.is_personal) ?? [])
}
export function getMainTeam(teams: Awaited<ReturnType<typeof getUserTeams>>) {
const sortedTeams = teams
?.filter((t) => t.is_active)
.sort(
(a, b) =>
tiersPriority.indexOf(a.tier as any) - tiersPriority.indexOf(b.tier as any)
)
return sortedTeams?.[0]
}
/**
* @deprecated
* TODO: we only have to check if the user has a subscription to the Pro plan.
* We can remove the check for owned products and the subscription_items.
*/
function checkAccessToProduct(
productSlug: string,
subscriptions: Awaited<ReturnType<typeof getSubscriptions>>,
ownedProducts: Awaited<ReturnType<typeof getOwnedProducts>>
) {
// Valid Pro products that grant access
const validProProducts = [
ProductName.GuiPro,
ProductName.GuiProV2,
ProductName.GuiProV2Upgrade,
ProductName.GuiSupportDirect,
ProductName.GuiSupportSponsor,
]
const hasActiveSubscription = subscriptions.some(
(subscription) =>
(subscription.status === SubscriptionStatus.Trialing ||
subscription.status === SubscriptionStatus.Active) &&
(subscription.subscription_items?.some(
(item) => getSingle(item.price.product?.metadata?.['slug']) === productSlug
) ||
subscription.subscription_items?.some((item) =>
validProProducts.some((product) => item.price.product?.name?.includes(product))
))
)
if (hasActiveSubscription) {
return {
access: true,
type: 'subscription' as const,
}
}
const hasLifetimeOwnership = ownedProducts.some(
(ownedProduct) =>
getSingle(ownedProduct.price?.product?.metadata?.['slug']) === productSlug
)
if (hasLifetimeOwnership) {
return {
access: true,
type: 'lifetime' as const,
}
}
return {
access: false,
}
}
/**
* @deprecated
* TODO: we only have to check if the user has a subscription to the Pro plan.
* We can remove the check for owned products and the subscription_items.
* However, for the backwards compatibility, we keep the function.
*/
/**
* Check if user has lifetime Bento access via product_ownership
* This is separate from Pro subscription - Bento is a lifetime purchase
*/
async function checkBentoAccess(userId: string): Promise<boolean> {
const result = await supabaseAdmin
.from('product_ownership')
.select(`
*,
prices (
*,
products (
*
)
)
`)
.eq('user_id', userId)
if (!result?.data?.length) {
return false
}
// check for direct Bento product ownership
const hasBentoOwnership = result.data.some(
(ownership) => ownership.prices?.products?.name === ProductName.GuiBento
)
if (hasBentoOwnership) {
return true
}
// check for legacy lifetime purchases with is_lifetime metadata
const hasLifetimeBento = result.data.some((ownership) => {
const productMetadata = ownership.prices?.metadata as Record<string, any> | null
const productName = ownership.prices?.products?.name
// only count as bento if it's a bento product with lifetime flag
return productMetadata?.is_lifetime === '1' && productName === ProductName.GuiBento
})
return hasLifetimeBento
}
export async function getUserAccessInfo(user: User | null) {
if (!user) {
return {
hasPro: false,
hasBento: false,
teamsWithAccess: [],
}
}
const [proAccess, bentoAccess, teams] = await Promise.all([
hasProAccess(user.id),
checkBentoAccess(user.id),
getUserTeams(user.id),
])
const teamsWithAccess = (teams || []).filter(
(team) =>
team.is_active || whitelistGithubUsernames.some((name) => team.name === name)
)
const hasTeamAccess = teamsWithAccess.length > 0
// user has Pro if they have subscription/legacy access OR team sponsorship access
const hasPro = proAccess || hasTeamAccess
// user has Bento if they have lifetime bento ownership OR pro access (pro includes bento)
const hasBento = bentoAccess || hasPro
return {
hasPro,
hasBento,
teamsWithAccess,
}
}
/**
* Retrieve the theme histories that the user has previously created
*
* @param user - Current user object
*/
export async function getUserThemeHistories(user: User | null) {
try {
if (!user) return []
// Get last few theme histories
const { data, error } = await supabaseAdmin
.from('theme_histories')
.select('theme_data, search_query, created_at, id')
.eq('user_id', user.id)
.order('updated_at', { ascending: false })
.limit(15)
if (error) {
return []
}
return data.map((d) => ({
...d,
theme_data: ThemeSuiteSchema.parse(d.theme_data) as unknown as ThemeSuiteItemData,
}))
} catch {
return []
}
}
/**
* Get the team eligibility for a user
* @param user - Current user object
*/
export async function getTeamEligibility(user: User | null) {
if (!user) {
return {
isProMember: false,
subscriptions: undefined,
}
}
try {
const teamMembership = await getActiveTeamMembership(user.id)
if (!teamMembership.isActive) {
return {
isProMember: false,
}
}
if (!teamMembership.ownerId) {
return {
isProMember: true,
}
}
const subscriptions = await getOwnerSubscriptions(teamMembership.ownerId)
return {
subscriptions,
isProMember: true,
}
} catch (err) {
return {
subscriptions: undefined,
isProMember: false,
}
}
}
/**
* Get active team membership for a user
*/
async function getActiveTeamMembership(userId: string) {
const { data: teamMember, error } = await supabaseAdmin
.from('team_members')
.select(`
team_subscription_id,
team_subscriptions (
owner_id
)
`)
.eq('member_id', userId)
.eq('status', 'active')
.single()
if (error || !teamMember?.team_subscription_id) {
return {
isActive: false,
ownerId: null,
}
}
return {
isActive: true,
ownerId: teamMember.team_subscriptions?.owner_id,
}
}
/**
* Get subscriptions for a team owner
*/
async function getOwnerSubscriptions(ownerId: string) {
const { data: subscriptions, error: subError } = await supabaseAdmin
.from('subscriptions')
.select(`
*,
subscription_items (
*,
prices (
*,
products (*)
),
app_installations (*)
)
`)
.eq('user_id', ownerId)
if (subError) {
throw new Error(subError.message)
}
return subscriptions.map((sub) => ({
...sub,
subscription_items: getArray(sub.subscription_items).map(({ prices, ...item }) => ({
...item,
price: {
...getSingle(prices),
product: getSingle(getSingle(prices)?.products),
},
})),
}))
}
@@ -1,96 +0,0 @@
import { useRouter } from 'one'
import { useEffect } from 'react'
import useSWR, { useSWRConfig } from 'swr'
import { Spinner, YStack } from '@hanzo/gui'
import { useOfflineMode } from '~/hooks/useOfflineMode'
import type { UserContextType } from '../auth/types'
import { userSubscriptionStatus } from './userSubscriptionStatus'
import { getAccessToken } from '../auth/useSupabaseClient'
export let currentUser: UserContextType | null = null
export const useUser = () => {
const { mutate } = useSWRConfig()
const response = useSWR<UserContextType | null>('user', {
fetcher: async () => {
if (typeof window === 'undefined') {
return null
}
// Get access token from localStorage-based auth
const accessToken = await getAccessToken()
const headers: HeadersInit = {}
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`
}
const res = await fetch('/api/user', { headers })
if (res.ok) {
return (await res.json()) as UserContextType
}
// in the case where you are unauthorized lets clear all cookies
// this is because we had a bad version of supabase ssr that caused bad cookies
// and users with those cookies cant sign in
await clearSupabaseCookies()
return null
},
refreshInterval: 0,
revalidateOnFocus: false,
revalidateIfStale: false,
refreshWhenHidden: false,
keepPreviousData: true,
})
useEffect(() => {
currentUser = response.data || null
}, [response])
return {
...response,
subscriptionStatus: userSubscriptionStatus(response.data ?? undefined),
refresh() {
mutate('user')
},
}
}
async function clearSupabaseCookies() {
try {
await fetch('/api/clear-supabase-cookies', {
method: 'POST',
credentials: 'same-origin',
cache: 'no-store',
})
} catch {
// stale cookies are cleanup-only; auth failure remains represented by null
}
}
export const UserGuard = ({ children }: { children: React.ReactNode }) => {
const { data, isLoading } = useUser()
const router = useRouter()
const isOffline = useOfflineMode()
const user = data?.user
useEffect(() => {
if (isOffline) {
return
}
if (!user && !isLoading) {
router.push(`/login`)
}
}, [isOffline, user, isLoading, router])
if (!user)
return (
<YStack items="center" flex={1} justify="center">
<Spinner size="large" />
</YStack>
)
return <>{children}</>
}
@@ -1,160 +0,0 @@
/**
* This file contains the whitelist of users who are allowed to access the subscription features.
*/
import type { UserContextType } from '~/features/auth/types'
import {
ProductName,
type ProductNameType,
SubscriptionStatus,
type UserSubscriptionStatus,
} from '~/shared/types/subscription'
/**
* GitHub usernames allowed to test the purchase flow while holding Pro. These
* users bypass the active-subscription check, unlock the DEV_TEST_99 coupon
* (99% off), and may repurchase the same domain. Empty, so no account
* qualifies.
*/
export const DEVELOPER_GITHUB_USERNAMES: readonly string[] = []
/**
* Check if a subscription has specific product access
*/
const hasProductAccess = (
subscriptions: UserContextType['subscriptions'],
productName: ProductNameType
): boolean => {
return (
subscriptions?.some(
(sub) =>
(sub.status === SubscriptionStatus.Active ||
sub.status === SubscriptionStatus.Trialing) &&
sub.subscription_items?.some((item) => item.price?.product?.name === productName)
) ?? false
)
}
/**
* Calculate support tier from subscription data
* V2 support tiers: direct = 1, sponsor = 2
* V1 support tier: based on quantity
*/
const calculateSupportTier = (
subscriptions: UserContextType['subscriptions']
): number => {
if (!subscriptions) return 0
// Check V2 sponsor support first (highest tier)
const hasSponsor = subscriptions.some(
(sub) =>
sub.subscription_items?.some(
(item) => item.price?.product?.name === ProductName.GuiSupportSponsor
) &&
(sub.status === SubscriptionStatus.Active ||
sub.status === SubscriptionStatus.Trialing)
)
if (hasSponsor) return 2
// Check V2 direct support
const hasDirect = subscriptions.some(
(sub) =>
sub.subscription_items?.some(
(item) => item.price?.product?.name === ProductName.GuiSupportDirect
) &&
(sub.status === SubscriptionStatus.Active ||
sub.status === SubscriptionStatus.Trialing)
)
if (hasDirect) return 1
// V1 support (legacy)
const supportItem = subscriptions.find((sub) =>
sub.subscription_items?.some(
(item) =>
item.price?.product?.name === ProductName.GuiSupport &&
(sub.status === SubscriptionStatus.Active ||
sub.status === SubscriptionStatus.Trialing)
)
)
return supportItem?.quantity ?? 0
}
const calculateTeamSeats = (subscriptions: UserContextType['subscriptions']): number => {
if (!subscriptions) return 0
return (
subscriptions.find((sub) =>
sub.subscription_items?.some(
(item) =>
item.price?.product?.name === ProductName.GuiProTeamSeats &&
(sub.status === SubscriptionStatus.Active ||
sub.status === SubscriptionStatus.Trialing)
)
)?.quantity ?? 0
)
}
/**
* Check if a user is a developer who can test the purchase flow
*/
const isDeveloperUser = (userData?: UserContextType): boolean => {
if (!userData?.githubUsername) return false
return DEVELOPER_GITHUB_USERNAMES.includes(userData.githubUsername.toLowerCase())
}
/**
* Get the subscription status of a user.
*/
export const userSubscriptionStatus = (
userData?: UserContextType
): UserSubscriptionStatus => {
if (!userData)
return {
pro: false,
proV1: false,
proV2: false,
chat: false,
supportTier: 0,
teamSeats: 0,
couponCodes: { previouslySubscribed: 'RENEWAL30' },
isDeveloper: false,
}
const hasV1Pro =
hasProductAccess(userData.subscriptions, ProductName.GuiPro) ||
hasProductAccess(userData.subscriptions, ProductName.GuiProTeamSeats)
const hasV2Pro =
hasProductAccess(userData.subscriptions, ProductName.GuiProV2) ||
hasProductAccess(userData.subscriptions, ProductName.GuiProV2Upgrade) ||
// V2 support tiers also imply Pro access (support is add-on to Pro license)
hasProductAccess(userData.subscriptions, ProductName.GuiSupportDirect) ||
hasProductAccess(userData.subscriptions, ProductName.GuiSupportSponsor)
const isPro = hasV1Pro || hasV2Pro
const isChat =
// V1 chat
hasProductAccess(userData.subscriptions, ProductName.GuiChat) ||
// V2 Pro includes basic chat support
hasProductAccess(userData.subscriptions, ProductName.GuiProV2) ||
hasProductAccess(userData.subscriptions, ProductName.GuiProV2Upgrade) ||
// V2 support tiers include chat
hasProductAccess(userData.subscriptions, ProductName.GuiSupportDirect) ||
hasProductAccess(userData.subscriptions, ProductName.GuiSupportSponsor)
const supportTier = calculateSupportTier(userData.subscriptions) || 0
return {
pro: isPro,
proV1: hasV1Pro,
proV2: hasV2Pro,
chat: isChat,
supportTier,
teamSeats: calculateTeamSeats(userData.subscriptions),
couponCodes: {
previouslySubscribed: 'RENEWAL30',
},
isDeveloper: isDeveloperUser(userData),
}
}
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 354 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

@@ -1,53 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="1156px" height="1116px" viewBox="0 0 1156 1116" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 61 (89581) - https://sketch.com -->
<title>trilemma</title>
<desc>Created with Sketch.</desc>
<g id="trilemma" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<circle id="Oval" fill-opacity="0.432366" fill="#FF00E0" cx="578" cy="372" r="372"></circle>
<circle id="Oval-Copy" fill-opacity="0.432366" fill="#FF00E0" cx="372" cy="744" r="372"></circle>
<circle id="Oval-Copy-2" fill-opacity="0.432366" fill="#FF00E0" cx="784" cy="744" r="372"></circle>
<g id="Group" transform="translate(61.000000, 166.000000)">
<text id="DX" font-family="Zen" font-size="173" font-weight="800" line-spacing="90" letter-spacing="-7.2" fill="#FFFFFF">
<tspan x="260" y="168">DX</tspan>
</text>
<text id="UX" font-family="Zen" font-size="173" font-weight="800" line-spacing="90" letter-spacing="-7.2" fill="#FFFFFF">
<tspan x="0" y="758">UX</tspan>
</text>
<text id="XP" font-family="Zen" font-size="173" font-weight="800" line-spacing="90" letter-spacing="-7.2" fill="#FFFFFF">
<tspan x="697" y="748">XP</tspan>
</text>
<text id="loads-fast,-feels-na" opacity="0.437290737" font-family="Zen" font-size="39" font-weight="400" line-spacing="49" letter-spacing="-1.3" fill="#FFFFFF">
<tspan x="93.3632457" y="757">loads fast,</tspan>
<tspan x="83.6746449" y="806">feels native</tspan>
</text>
<text id="cross-platform-nativ" opacity="0.437290737" font-family="Zen" font-size="39" font-weight="400" line-spacing="49" letter-spacing="-1.3" fill="#FFFFFF">
<tspan x="702.635866" y="757">cross platform</tspan>
<tspan x="716.092436" y="806">native + web</tspan>
</text>
<text id="less,-better-code" opacity="0.437290737" font-family="Zen" font-size="39" font-weight="400" line-spacing="49" letter-spacing="-1.3" fill="#FFFFFF">
<tspan x="370.662216" y="157">less, better code</tspan>
</text>
<text id="Ship,-😍-code..-slow" font-family="Zen" font-size="58" font-weight="800" line-spacing="51" letter-spacing="-1.48717949" fill="#FFFFFF">
<tspan x="612.168943" y="294.502841">Ship, </tspan>
<tspan x="616.494796" y="352.9999" font-family="AppleColorEmoji, Apple Color Emoji" font-weight="normal">😍</tspan>
<tspan x="618.967931" y="345.502841"> code..</tspan>
<tspan x="615.585865" y="396.502841">slow</tspan>
</text>
<text id="Ship-once." font-family="Zen" font-size="58" font-weight="800" line-spacing="51" letter-spacing="-1.48717949" fill="#FFFFFF">
<tspan x="197.992206" y="336">Ship </tspan>
<tspan x="183" y="387">once.</tspan>
</text>
<text id="Never-actually-ship." font-family="Zen" font-size="59" font-weight="800" line-spacing="49" letter-spacing="-1.451282" fill="#FFC800">
<tspan x="430.046799" y="423">Never </tspan>
<tspan x="400" y="472">actually </tspan>
<tspan x="443.497793" y="521">ship.</tspan>
</text>
<text id="Ship-everything-twic" font-family="Zen" font-size="58" font-weight="800" line-spacing="51" letter-spacing="-1.48717949" fill="#FFFFFF">
<tspan x="438.582496" y="659">Ship </tspan>
<tspan x="349" y="710">everything</tspan>
<tspan x="415.518539" y="761">twice.</tspan>
</text>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

+1
View File
@@ -8,6 +8,7 @@
"rewriteRelativeImportExtensions": true,
"sourceMap": true,
"inlineSourceMap": false,
"paths": {},
"module": "commonjs",
"moduleResolution": "bundler",
"outDir": "dist/cjs",

Some files were not shown because too many files have changed in this diff Show More