diff --git a/.github/workflows/desktop_cd.yaml b/.github/workflows/desktop_cd.yaml index f2eab2a80e..4125a59cfb 100644 --- a/.github/workflows/desktop_cd.yaml +++ b/.github/workflows/desktop_cd.yaml @@ -99,7 +99,8 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} VITE_SUPABASE_URL: ${{ secrets.SUPABASE_URL }} VITE_SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_PUBLISHABLE_KEY }} - VITE_APP_URL: ${{ secrets.VITE_APP_URL }} + VITE_APP_URL: "https://hyprnote.com" + VITE_API_URL: "https://api.hyprnote.com" VITE_PRO_PRODUCT_ID: ${{ secrets.VITE_PRO_PRODUCT_ID }} - run: | mkdir -p target/release/ diff --git a/.prettierrc b/.prettierrc index 3628572d4f..52620d787d 100644 --- a/.prettierrc +++ b/.prettierrc @@ -6,6 +6,7 @@ importOrder: - "^\\./instrument$" - "" - "^@hypr/(.*)$" + - "^@/(.*)$" - "^[./]" importOrderSeparation: true importOrderSortSpecifiers: true diff --git a/Taskfile.yaml b/Taskfile.yaml index 8730143512..604178c2fb 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -236,27 +236,22 @@ tasks: generated_env_file=".stripe/generated.env" env_target="apps/api/.env" - temp_fifo=$(mktemp -u) - mkfifo "$temp_fifo" cleanup() { - rm -f "$temp_fifo" "$generated_env_file" + rm -f "$generated_env_file" } trap cleanup EXIT mkdir -p "$(dirname "$generated_env_file")" - (stripe listen --skip-verify --forward-to http://localhost:8787/webhook/stripe 2>&1 | tee "$temp_fifo") & - STRIPE_PID=$! + secret=$(stripe listen --print-secret | tr -d '\r\n') - awk ' - /Your webhook signing secret is whsec_/ { - match($0, /whsec_[a-zA-Z0-9]+/) - secret = substr($0, RSTART, RLENGTH) - print "STRIPE_WEBHOOK_SECRET=\"" secret "\"" - exit - } - ' "$temp_fifo" > "$generated_env_file" + if [ -z "$secret" ]; then + echo "failed to retrieve Stripe webhook secret" >&2 + exit 1 + fi + + printf 'STRIPE_WEBHOOK_SECRET="%s"\n' "$secret" > "$generated_env_file" if [ -s "$generated_env_file" ]; then mkdir -p "$(dirname "$env_target")" @@ -311,5 +306,7 @@ tasks: echo "✓ Webhook secret written to $env_target" fi - wait $STRIPE_PID + rm -f "$generated_env_file" + + stripe listen --skip-verify --forward-to http://localhost:8787/webhook/stripe EOF diff --git a/apps/api/.env.sample b/apps/api/.env.sample index e98c7623b9..c247793a94 100644 --- a/apps/api/.env.sample +++ b/apps/api/.env.sample @@ -1,5 +1,5 @@ STRIPE_API_KEY="" -STRIPE_WEBHOOK_SIGNING_SECRET="" +STRIPE_WEBHOOK_SECRET="" OPENROUTER_API_KEY="" SUPABASE_ANON_KEY="" diff --git a/apps/api/package.json b/apps/api/package.json index 61809846d8..b084029041 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "dev": "env-cmd -f ./.env -- bun --hot src/index.ts", + "dev": "env-cmd --silent -f ./.env -- bun --hot src/index.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/api/src/billing.ts b/apps/api/src/billing.ts index 2457fc8f0b..3bbafbbc4b 100644 --- a/apps/api/src/billing.ts +++ b/apps/api/src/billing.ts @@ -35,9 +35,6 @@ export async function syncBillingState(customerId: string) { const userId = getUserIdFromCustomer(customer); if (!userId) { - console.warn( - `[STRIPE SYNC] Missing user id metadata for customer ${customer.id}`, - ); return; } @@ -71,14 +68,8 @@ export async function syncBillingState(customerId: string) { .upsert(payload, { onConflict: "user_id" }); if (error) { - throw new Error( - `[STRIPE SYNC] Failed to sync billing for user ${userId}: ${error.message}`, - ); + throw error; } - - console.log( - `[STRIPE SYNC] Synced billing for user ${userId} (customer ${customer.id})`, - ); } export async function syncBillingForStripeEvent(event: Stripe.Event) { @@ -89,9 +80,6 @@ export async function syncBillingForStripeEvent(event: Stripe.Event) { const customerId = getCustomerId(event.data.object); if (!customerId) { - console.warn( - `[STRIPE WEBHOOK] Missing customer id for event ${event.type}`, - ); return; } diff --git a/apps/api/src/deepgram.ts b/apps/api/src/deepgram.ts index 51e7297d33..10fb4fb50b 100644 --- a/apps/api/src/deepgram.ts +++ b/apps/api/src/deepgram.ts @@ -365,8 +365,9 @@ export const buildDeepgramUrl = (incomingUrl: URL) => { const target = new URL("wss://api.deepgram.com/v1/listen"); incomingUrl.searchParams.forEach((value, key) => { - target.searchParams.append(key, value); + target.searchParams.set(key, value); }); + target.searchParams.set("model", "nova-3-general"); target.searchParams.set("mip_opt_out", "false"); return target; diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index d26885a917..a27767396c 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -11,7 +11,7 @@ export const env = createEnv({ SUPABASE_ANON_KEY: z.string().min(1), SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), STRIPE_API_KEY: z.string().min(1), - STRIPE_WEBHOOK_SIGNING_SECRET: z.string().min(1), + STRIPE_WEBHOOK_SECRET: z.string().min(1), OPENROUTER_API_KEY: z.string().min(1), DEEPGRAM_API_KEY: z.string().min(1), }, diff --git a/apps/api/src/stripe.ts b/apps/api/src/stripe.ts index 3d9a19fc3b..c59307def2 100644 --- a/apps/api/src/stripe.ts +++ b/apps/api/src/stripe.ts @@ -19,12 +19,11 @@ export const verifyStripeWebhook = createMiddleware<{ } const body = await c.req.text(); - try { const event = await stripe.webhooks.constructEventAsync( body, signature, - env.STRIPE_WEBHOOK_SIGNING_SECRET, + env.STRIPE_WEBHOOK_SECRET, undefined, cryptoProvider, ); @@ -32,6 +31,7 @@ export const verifyStripeWebhook = createMiddleware<{ c.set("stripeEvent", event); await next(); } catch (err) { + console.error(err); const message = err instanceof Error ? err.message : "unknown_error"; return c.text(message, 400); } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0b6264f003..14211f9ad1 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,10 +7,10 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "tauri": "env-cmd -f ./.env -- tauri", + "tauri": "env-cmd --silent -f ./.env -- tauri", "typecheck": "tsc --noEmit", "test": "vitest run", - "tauri:dev": "env-cmd -f ./.env -- tauri dev", + "tauri:dev": "env-cmd --silent -f ./.env -- tauri dev", "tauri:build": "tauri build", "gen:schema": "tsx src/devtool/seed/script.ts" }, diff --git a/apps/desktop/src/billing.tsx b/apps/desktop/src/billing.tsx new file mode 100644 index 0000000000..c4f82f5c8c --- /dev/null +++ b/apps/desktop/src/billing.tsx @@ -0,0 +1,150 @@ +import { useQuery } from "@tanstack/react-query"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useMemo, +} from "react"; +import type Stripe from "stripe"; + +import { useAuth } from "./auth"; +import { env } from "./env"; + +type BillingRow = { + id: string; + user_id: string; + created_at: string; + updated_at: string; + stripe_customer: Stripe.Customer | null; + stripe_subscription: Stripe.Subscription | null; +}; + +type BillingData = (BillingRow & { isPro: boolean }) | null; + +type BillingContextValue = { + data: BillingData; + isPro: boolean; + isLoading: boolean; + isPending: boolean; + isFetching: boolean; + isRefetching: boolean; + isError: boolean; + error: unknown; + refetch: () => Promise; + upgradeToPro: () => void; +}; + +export type BillingAccess = BillingContextValue; + +const BillingContext = createContext(null); + +export function BillingProvider({ children }: { children: ReactNode }) { + const auth = useAuth(); + + const { + data: queryData, + isLoading, + isPending, + isFetching, + isRefetching, + isError, + error, + refetch, + } = useQuery({ + enabled: !!auth?.supabase && !!auth?.session?.user?.id, + queryKey: ["billing", auth?.session?.user?.id], + queryFn: async (): Promise => { + if (!auth?.supabase || !auth?.session?.user?.id) { + return null; + } + + const { data, error } = await auth.supabase + .from("billings") + .select("*") + .eq("user_id", auth.session.user.id) + .maybeSingle(); + + if (error) { + throw error; + } + + if (!data) { + return null; + } + + const billing = data as BillingRow; + return { + ...billing, + isPro: computeIsPro(billing.stripe_subscription), + }; + }, + }); + + const data = queryData ?? null; + + const upgradeToPro = useCallback(() => { + openUrl(`${env.VITE_APP_URL}/app/checkout?period=monthly`); + }, [auth]); + + const value = useMemo( + () => ({ + data, + isPro: !!data?.isPro, + isLoading, + isPending, + isFetching, + isRefetching, + isError, + error, + refetch: () => refetch(), + upgradeToPro, + }), + [ + data, + error, + isError, + isFetching, + isLoading, + isPending, + isRefetching, + refetch, + upgradeToPro, + ], + ); + + return ( + {children} + ); +} + +export function useBillingAccess() { + const context = useContext(BillingContext); + + if (!context) { + throw new Error("useBillingAccess must be used within BillingProvider"); + } + + return context; +} + +function computeIsPro( + subscription: Stripe.Subscription | null | undefined, +): boolean { + if (!subscription) { + return false; + } + + const hasValidStatus = ["active", "trialing"].includes(subscription.status); + + const hasProProduct = subscription.items.data.some((item) => { + const product = item.price.product; + + return typeof product === "string" + ? product === env.VITE_PRO_PRODUCT_ID + : product.id === env.VITE_PRO_PRODUCT_ID; + }); + + return hasValidStatus && hasProProduct; +} diff --git a/apps/desktop/src/components/settings/account.tsx b/apps/desktop/src/components/settings/account.tsx index ed24883c30..e6db188edd 100644 --- a/apps/desktop/src/components/settings/account.tsx +++ b/apps/desktop/src/components/settings/account.tsx @@ -1,221 +1,132 @@ import { openUrl } from "@tauri-apps/plugin-opener"; -import { Check } from "lucide-react"; -import { useCallback, useState } from "react"; +import { ExternalLinkIcon } from "lucide-react"; +import { type ReactNode, useCallback } from "react"; +import type Stripe from "stripe"; import { Button } from "@hypr/ui/components/ui/button"; -import { cn } from "@hypr/utils"; +import { useAuth } from "../../auth"; +import { useBillingAccess } from "../../billing"; import { env } from "../../env"; -export function SettingsAccount() { - const handleOpenInBrowser = useCallback(() => { - const base = env.VITE_APP_URL ?? "http://localhost:3000"; - openUrl(`${base}/app/account`); - }, []); +const WEB_APP_BASE_URL = env.VITE_APP_URL ?? "http://localhost:3000"; - return ( -
-

Manage account

- - -
- ); -} +export function SettingsAccount() { + const auth = useAuth(); + const billing = useBillingAccess(); -function SettingsBilling() { - const [currentPlan, setCurrentPlan] = useState("free"); + const isAuthenticated = !!auth?.session; - const handlePlanChange = useCallback((nextPlan: PlanId) => { - setCurrentPlan(nextPlan); - console.log(`[billing] Requested plan change to ${nextPlan}`); + const handleOpenAccount = useCallback(() => { + openUrl(`${WEB_APP_BASE_URL}/app/account`); }, []); - const handleContact = useCallback(() => { - openUrl("https://cal.com/team/hyprnote/welcome"); - }, []); + const handleSignIn = useCallback(() => { + auth?.signIn(); + }, [auth]); + + if (!isAuthenticated) { + return ( + + Get Started + + } + > + ); + } + + const hasStripeCustomer = !!billing.data?.stripe_customer; return ( -
-
-
- {PLANS.map((plan, index) => ( -
+ + Open + + + } + > + + - -
- ))} -
-
+ Manage + + + ) : undefined + } + > + {billing.data?.stripe_subscription && ( + + )} +
); } -function BillingPlanCard({ - plan, - currentPlan, - onChangePlan, - onContactSales, - className, - removeBorder = false, +function SubscriptionDetails({ + subscription, }: { - plan: BillingPlan; - currentPlan: PlanId; - onChangePlan: (plan: PlanId) => void; - onContactSales: () => void; - className?: string; - removeBorder?: boolean; + subscription: Stripe.Subscription; }) { - const isCurrent = plan.id === currentPlan; + const { + status, + items: { + data: [{ current_period_end, current_period_start }], + }, + } = subscription; return ( -
-
-
-

{plan.name}

-

{plan.description}

-
- -
    - {plan.features.map((feature) => ( -
  • - - {feature} -
  • - ))} -
- -

- {plan.price}{" "} - {plan.priceSuffix && ( - - {plan.priceSuffix} - - )} -

- - -
+
+ {status}: + {new Date(current_period_start * 1000).toLocaleDateString()} + ~ + {new Date(current_period_end * 1000).toLocaleDateString()}
); } -function PlanActions({ - planId, - currentPlan, - onChangePlan, - onContactSales, - isCurrent, +function Container({ + title, + description, + action, + children, }: { - planId: PlanId; - currentPlan: PlanId; - onChangePlan: (plan: PlanId) => void; - onContactSales: () => void; - isCurrent: boolean; + title: string; + description: string; + action?: ReactNode; + children?: ReactNode; }) { - if (isCurrent) { - return ( - - ); - } - - if (planId === "free") { - return ( - - ); - } - - if (planId === "pro") { - if (currentPlan === "free") { - return ( - - ); - } - - return ( - - ); - } - return ( - +
+
+
+

{title}

+

{description}

+
+ {action ?
{action}
: null} +
+ {children} +
); } - -type PlanId = "free" | "pro"; - -interface BillingPlan { - id: PlanId; - name: string; - description: string; - price: string; - priceSuffix?: string; - features: string[]; -} - -const PLANS: BillingPlan[] = [ - { - id: "free", - name: "Free", - description: "Get started with local transcription and essential exports.", - price: "$0", - priceSuffix: "per month", - features: [ - "Local transcription with BYOK intelligence", - "Copy and PDF sharing", - "Community support", - ], - }, - { - id: "pro", - name: "Pro", - description: "Unlock cloud enhancements and org-ready workflows.", - price: "$19", - priceSuffix: "per seat / month", - features: [ - "Local + cloud transcription", - "Shareable links with viewer permissions", - "Unified billing and access controls", - ], - }, -]; diff --git a/apps/desktop/src/components/settings/ai/index.tsx b/apps/desktop/src/components/settings/ai/index.tsx index 3e8e62f01b..4d8c48ccc0 100644 --- a/apps/desktop/src/components/settings/ai/index.tsx +++ b/apps/desktop/src/components/settings/ai/index.tsx @@ -15,7 +15,6 @@ export function SettingsAI() { const [activeTab, setActiveTab] = useState<"transcription" | "intelligence">( "transcription", ); - return ( { if (!provider && config.baseUrl && !config.apiKey) { @@ -83,8 +92,14 @@ function NonHyprProviderCard({ - +
{config.icon} {config.displayName} @@ -92,48 +107,56 @@ function NonHyprProviderCard({ -
{ - e.preventDefault(); - e.stopPropagation(); - }} - > - {!config.baseUrl && ( - - {(field) => ( - - )} - - )} - {config?.apiKey && ( - - {(field) => ( - - )} - - )} - {config.baseUrl && ( -
- - Advanced - -
- - {(field) => ( - - )} - -
-
- )} -
+ {locked ? ( + + ) : ( +
{ + e.preventDefault(); + e.stopPropagation(); + }} + > + {!config.baseUrl && ( + + {(field) => ( + + )} + + )} + {config?.apiKey && ( + + {(field) => ( + + )} + + )} + {config.baseUrl && ( +
+ + Advanced + +
+ + {(field) => ( + + )} + +
+
+ )} +
+ )}
); @@ -148,12 +171,21 @@ function HyprProviderCard({ providerName: string; icon: React.ReactNode; }) { + const billing = useBillingAccess(); + const locked = providerId === "hyprnote" && !billing.isPro; + return ( - +
{icon} {providerName} @@ -164,23 +196,39 @@ function HyprProviderCard({ + {locked ? ( + + ) : null} ); } function ProviderContext({ providerId }: { providerId: ProviderId }) { + const { isPro, upgradeToPro } = useBillingAccess(); + const content = providerId === "hyprnote" - ? "The Hyprnote team continuously tests different models to provide the **best performance & reliability.**" + ? "We continuously test models to provide the **best performance & reliability.**" : providerId === "lmstudio" ? "- Ensure LM Studio server is **running.** (Default port is 1234)\n- Enable **CORS** in LM Studio config." : providerId === "custom" - ? "We only support **OpenAI compatible** endpoints for now." + ? "We only support **OpenAI-compatible** endpoints for now." : providerId === "openrouter" ? "We filter out models from the combobox based on heuristics like **input modalities** and **tool support**." : ""; + if (providerId === "hyprnote" && !isPro) { + return ( +
+ {content} + +
+ ); + } + if (!content) { return null; } diff --git a/apps/desktop/src/components/settings/ai/llm/health.tsx b/apps/desktop/src/components/settings/ai/llm/health.tsx index 69a7c4b6c5..d3b2dc39ac 100644 --- a/apps/desktop/src/components/settings/ai/llm/health.tsx +++ b/apps/desktop/src/components/settings/ai/llm/health.tsx @@ -2,11 +2,12 @@ import { useQuery } from "@tanstack/react-query"; import { generateText } from "ai"; import { useEffect, useMemo } from "react"; +import { useBillingAccess } from "../../../../billing"; import { useConfigValues } from "../../../../config/use-config"; import { useLanguageModel } from "../../../../hooks/useLLMConnection"; import * as main from "../../../../store/tinybase/main"; import { AvailabilityHealth, ConnectionHealth } from "../shared/health"; -import { PROVIDERS } from "./shared"; +import { llmProviderRequiresPro, PROVIDERS } from "./shared"; export function HealthCheckForConnection() { const health = useConnectionHealth(); @@ -85,6 +86,7 @@ function useAvailability() { "current_llm_provider", "current_llm_model", ] as const); + const billing = useBillingAccess(); const configuredProviders = main.UI.useResultTable( main.QUERIES.llmProviders, @@ -106,11 +108,12 @@ function useAvailability() { }; } - if (!configuredProviders[current_llm_provider]?.base_url) { + if (llmProviderRequiresPro(current_llm_provider) && !billing.isPro) { return { available: false, - message: - "Provider not configured. Please configure the provider below.", + message: billing.isLoading + ? "Checking plan access for this provider..." + : "Upgrade to Pro to use this provider.", }; } diff --git a/apps/desktop/src/components/settings/ai/llm/select.tsx b/apps/desktop/src/components/settings/ai/llm/select.tsx index 44949f6add..9a6a719c25 100644 --- a/apps/desktop/src/components/settings/ai/llm/select.tsx +++ b/apps/desktop/src/components/settings/ai/llm/select.tsx @@ -11,6 +11,7 @@ import { import { cn } from "@hypr/utils"; import { useAuth } from "../../../../auth"; +import { useBillingAccess } from "../../../../billing"; import { useConfigValues } from "../../../../config/use-config"; import * as main from "../../../../store/tinybase/main"; import type { ListModelsResult } from "../shared/list-common"; @@ -33,6 +34,7 @@ export function SelectProviderAndModel() { "current_llm_model", "current_llm_provider", ] as const); + const billing = useBillingAccess(); const handleSelectProvider = main.UI.useSetValueCallback( "current_llm_provider", @@ -86,8 +88,12 @@ export function SelectProviderAndModel() { { - form.setFieldValue("model", ""); + onChange: ({ value }) => { + if (value === "hyprnote") { + form.setFieldValue("model", "Auto"); + } else { + form.setFieldValue("model", ""); + } }, }} > @@ -101,18 +107,29 @@ export function SelectProviderAndModel() { - {PROVIDERS.map((provider) => ( - -
- {provider.icon} - {provider.displayName} -
-
- ))} + {PROVIDERS.map((provider) => { + const locked = provider.requiresPro && !billing.isPro; + const configured = configuredProviders[provider.id]; + return ( + +
+
+ {provider.icon} + {provider.displayName} +
+ {locked ? ( + + Upgrade to Pro to use this provider. + + ) : null} +
+
+ ); + })}
@@ -126,8 +143,14 @@ export function SelectProviderAndModel() { const providerId = form.getFieldValue("provider"); const maybeListModels = configuredProviders[providerId]; + const providerDef = PROVIDERS.find( + (provider) => provider.id === providerId, + ); + const providerRequiresPro = providerDef?.requiresPro ?? false; + const locked = providerRequiresPro && !billing.isPro; + const listModels = () => { - if (!maybeListModels) { + if (!maybeListModels || locked) { return { models: [], ignored: [] }; } return maybeListModels(); @@ -139,9 +162,15 @@ export function SelectProviderAndModel() { providerId={providerId} value={field.state.value} onChange={(value) => field.handleChange(value)} - disabled={!maybeListModels} + disabled={!maybeListModels || locked} listModels={listModels} /> + {locked ? ( +

+ Upgrade to Pro to pick{" "} + {providerDef?.displayName ?? "this provider"} models. +

+ ) : null}
); }} @@ -161,6 +190,7 @@ function useConfiguredMapping(): Record< null | (() => Promise) > { const auth = useAuth(); + const billing = useBillingAccess(); const configuredProviders = main.UI.useResultTable( main.QUERIES.llmProviders, main.STORE_ID, @@ -169,6 +199,10 @@ function useConfiguredMapping(): Record< const mapping = useMemo(() => { return Object.fromEntries( PROVIDERS.map((provider) => { + if (provider.requiresPro && !billing.isPro) { + return [provider.id, null]; + } + if (provider.id === "hyprnote") { if (!auth?.session) { return [provider.id, null]; @@ -219,7 +253,7 @@ function useConfiguredMapping(): Record< return [provider.id, listModelsFunc]; }), ) as Record Promise)>; - }, [configuredProviders, auth]); + }, [configuredProviders, auth, billing]); return mapping; } diff --git a/apps/desktop/src/components/settings/ai/llm/shared.tsx b/apps/desktop/src/components/settings/ai/llm/shared.tsx index 6068f398e8..477cee7f56 100644 --- a/apps/desktop/src/components/settings/ai/llm/shared.tsx +++ b/apps/desktop/src/components/settings/ai/llm/shared.tsx @@ -6,6 +6,17 @@ import { OpenAI, OpenRouter, } from "@lobehub/icons"; +import type { ReactNode } from "react"; + +type Provider = { + id: string; + displayName: string; + badge: string | null; + icon: ReactNode; + apiKey: boolean; + baseUrl?: string; + requiresPro?: boolean; +}; export type ProviderId = (typeof PROVIDERS)[number]["id"]; @@ -17,6 +28,7 @@ export const PROVIDERS = [ icon: Hyprnote, apiKey: false, baseUrl: "/functions/v1/llm", + requiresPro: true, }, { id: "openrouter", @@ -25,6 +37,7 @@ export const PROVIDERS = [ icon: , apiKey: true, baseUrl: "https://openrouter.ai/api/v1", + requiresPro: false, }, { id: "openai", @@ -33,6 +46,7 @@ export const PROVIDERS = [ icon: , apiKey: true, baseUrl: "https://api.openai.com/v1", + requiresPro: false, }, { id: "anthropic", @@ -41,6 +55,7 @@ export const PROVIDERS = [ icon: , apiKey: true, baseUrl: "https://api.anthropic.com/v1", + requiresPro: false, }, { id: "custom", @@ -49,6 +64,7 @@ export const PROVIDERS = [ icon: , apiKey: true, baseUrl: undefined, + requiresPro: false, }, { id: "lmstudio", @@ -57,6 +73,7 @@ export const PROVIDERS = [ icon: , apiKey: false, baseUrl: "http://127.0.0.1:1234/v1", + requiresPro: false, }, { id: "ollama", @@ -65,5 +82,10 @@ export const PROVIDERS = [ icon: , apiKey: false, baseUrl: "http://127.0.0.1:11434/v1", + requiresPro: false, }, -] as const; +] as const satisfies readonly Provider[]; + +export const llmProviderRequiresPro = (providerId: ProviderId) => + PROVIDERS.find((provider) => provider.id === providerId)?.requiresPro ?? + false; diff --git a/apps/desktop/src/components/settings/ai/shared/index.tsx b/apps/desktop/src/components/settings/ai/shared/index.tsx index 773eab7afa..b006d1906e 100644 --- a/apps/desktop/src/components/settings/ai/shared/index.tsx +++ b/apps/desktop/src/components/settings/ai/shared/index.tsx @@ -120,3 +120,11 @@ export function FormField({
); } + +export function PlanLockMessage({ message }: { message: string }) { + return ( +
+ {message} +
+ ); +} diff --git a/apps/desktop/src/components/settings/ai/stt/configure.tsx b/apps/desktop/src/components/settings/ai/stt/configure.tsx index 4140ec1e2a..0740311e26 100644 --- a/apps/desktop/src/components/settings/ai/stt/configure.tsx +++ b/apps/desktop/src/components/settings/ai/stt/configure.tsx @@ -18,6 +18,7 @@ import { import { Button } from "@hypr/ui/components/ui/button"; import { cn } from "@hypr/utils"; +import { useBillingAccess } from "../../../../billing"; import { useListener } from "../../../../contexts/listener"; import * as main from "../../../../store/tinybase/main"; import { aiProviderSchema } from "../../../../store/tinybase/main"; @@ -26,7 +27,12 @@ import { registerDownloadProgressCallback, unregisterDownloadProgressCallback, } from "../../../task-manager"; -import { FormField, StyledStreamdown, useProvider } from "../shared"; +import { + FormField, + PlanLockMessage, + StyledStreamdown, + useProvider, +} from "../shared"; import { ProviderId, PROVIDERS, sttModelQueries } from "./shared"; export function ConfigureProviders() { @@ -56,7 +62,9 @@ function NonHyprProviderCard({ }: { config: (typeof PROVIDERS)[number]; }) { + const billing = useBillingAccess(); const [provider, setProvider] = useProvider(config.id); + const locked = config.requiresPro && !billing.isPro; const form = useForm({ onSubmit: ({ value }) => setProvider(value), @@ -86,14 +94,14 @@ function NonHyprProviderCard({ return (
@@ -103,46 +111,54 @@ function NonHyprProviderCard({ -
{ - e.preventDefault(); - e.stopPropagation(); - }} - > - {!config.baseUrl && ( - + {locked ? ( + + ) : ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + {!config.baseUrl && ( + + {(field) => ( + + )} + + )} + {(field) => ( - + )} - )} - - {(field) => ( - + {config.baseUrl && ( +
+ + Advanced + +
+ + {(field) => ( + + )} + +
+
)} -
- {config.baseUrl && ( -
- - Advanced - -
- - {(field) => ( - - )} - -
-
- )} - + + )}
); @@ -223,6 +239,31 @@ function HyprProviderRow({ children }: { children: React.ReactNode }) { } function HyprProviderCloudRow() { + const { isPro, upgradeToPro } = useBillingAccess(); + + const handleSelectProvider = main.UI.useSetValueCallback( + "current_stt_provider", + (provider: string) => provider, + [], + main.STORE_ID, + ); + + const handleSelectModel = main.UI.useSetValueCallback( + "current_stt_model", + (model: string) => model, + [], + main.STORE_ID, + ); + + const handleClick = useCallback(() => { + if (!isPro) { + upgradeToPro(); + } else { + handleSelectProvider("hyprnote"); + handleSelectModel("cloud"); + } + }, [isPro, upgradeToPro, handleSelectProvider, handleSelectModel]); + return (
@@ -233,12 +274,13 @@ function HyprProviderCloudRow() {
diff --git a/apps/desktop/src/components/settings/ai/stt/health.tsx b/apps/desktop/src/components/settings/ai/stt/health.tsx index 10ea712ed1..1e2c9db1c4 100644 --- a/apps/desktop/src/components/settings/ai/stt/health.tsx +++ b/apps/desktop/src/components/settings/ai/stt/health.tsx @@ -1,10 +1,15 @@ import { useQueries } from "@tanstack/react-query"; +import { useBillingAccess } from "../../../../billing"; import { useConfigValues } from "../../../../config/use-config"; import { useSTTConnection } from "../../../../hooks/useSTTConnection"; -import * as main from "../../../../store/tinybase/main"; import { AvailabilityHealth, ConnectionHealth } from "../shared/health"; -import { type ProviderId, PROVIDERS, sttModelQueries } from "./shared"; +import { + type ProviderId, + PROVIDERS, + sttModelQueries, + sttProviderRequiresPro, +} from "./shared"; export function HealthCheckForConnection() { const props = useConnectionHealth(); @@ -13,11 +18,16 @@ export function HealthCheckForConnection() { function useConnectionHealth(): Parameters[0] { const { conn, local } = useSTTConnection(); - const { current_stt_provider } = useConfigValues([ + const { current_stt_provider, current_stt_model } = useConfigValues([ "current_stt_provider", + "current_stt_model", ] as const); - if (current_stt_provider !== "hyprnote") { + const isCloud = + current_stt_provider === "hyprnote" || current_stt_model === "cloud"; + + console.log(conn); + if (isCloud) { return conn ? { status: "success" } : { status: "error", tooltip: "Provider not configured." }; @@ -56,11 +66,7 @@ function useAvailability(): "current_stt_provider", "current_stt_model", ] as const); - - const configuredProviders = main.UI.useResultTable( - main.QUERIES.sttProviders, - main.STORE_ID, - ); + const billing = useBillingAccess(); const [p2, p3, tinyEn, smallEn] = useQueries({ queries: [ @@ -82,6 +88,15 @@ function useAvailability(): return { available: false, message: "Selected provider not found." }; } + if (sttProviderRequiresPro(providerId) && !billing.isPro) { + return { + available: false, + message: billing.isLoading + ? "Checking plan access for this provider..." + : "Upgrade to Pro to use this provider.", + }; + } + if (providerId === "hyprnote") { const downloadedModels = [ { id: "am-parakeet-v2", isDownloaded: p2.data ?? false }, @@ -103,16 +118,6 @@ function useAvailability(): return { available: true }; } - const config = configuredProviders[providerId] as - | main.AIProviderStorage - | undefined; - if (!config) { - return { - available: false, - message: "Provider not configured. Please configure the provider below.", - }; - } - if (providerId === "custom") { return { available: true }; } diff --git a/apps/desktop/src/components/settings/ai/stt/select.tsx b/apps/desktop/src/components/settings/ai/stt/select.tsx index ed530cc7f4..1b93197228 100644 --- a/apps/desktop/src/components/settings/ai/stt/select.tsx +++ b/apps/desktop/src/components/settings/ai/stt/select.tsx @@ -11,6 +11,7 @@ import { } from "@hypr/ui/components/ui/select"; import { cn } from "@hypr/utils"; +import { useBillingAccess } from "../../../../billing"; import { useConfigValues } from "../../../../config/use-config"; import * as main from "../../../../store/tinybase/main"; import { HealthCheckForConnection } from "./health"; @@ -26,6 +27,7 @@ export function SelectProviderAndModel() { "current_stt_provider", "current_stt_model", ] as const); + const billing = useBillingAccess(); const configuredProviders = useConfiguredMapping(); const handleSelectProvider = main.UI.useSetValueCallback( @@ -97,24 +99,37 @@ export function SelectProviderAndModel() { {PROVIDERS.filter(({ disabled }) => !disabled).map( - (provider) => ( - -
- {provider.icon} - {provider.displayName} -
-
- ), + (provider) => { + const configured = + configuredProviders[provider.id]?.configured ?? false; + const locked = provider.requiresPro && !billing.isPro; + return ( + +
+
+ {provider.icon} + {provider.displayName} + {provider.requiresPro ? ( + + Pro + + ) : null} +
+ {locked ? ( + + Upgrade to Pro to use this provider. + + ) : null} +
+
+ ); + }, )}
@@ -146,6 +161,9 @@ export function SelectProviderAndModel() { const allModels = configuredProviders?.[providerId]?.models ?? []; const models = allModels.filter((model) => { + if (model.id === "cloud") { + return true; + } if (model.id.startsWith("Quantized")) { return model.isDownloaded; } @@ -195,6 +213,7 @@ function useConfiguredMapping(): Record< models: Array<{ id: string; isDownloaded: boolean }>; } > { + const billing = useBillingAccess(); const configuredProviders = main.UI.useResultTable( main.QUERIES.sttProviders, main.STORE_ID, @@ -211,12 +230,17 @@ function useConfiguredMapping(): Record< return Object.fromEntries( PROVIDERS.map((provider) => { + if (provider.requiresPro && !billing.isPro) { + return [provider.id, { configured: false, models: [] }]; + } + if (provider.id === "hyprnote") { return [ provider.id, { configured: true, models: [ + { id: "cloud", isDownloaded: billing.isPro }, { id: "am-parakeet-v2", isDownloaded: p2.data ?? false }, { id: "am-parakeet-v3", isDownloaded: p3.data ?? false }, { id: "QuantizedTinyEn", isDownloaded: tinyEn.data ?? false }, diff --git a/apps/desktop/src/components/settings/ai/stt/shared.tsx b/apps/desktop/src/components/settings/ai/stt/shared.tsx index 9e12343f75..4de1f3fae0 100644 --- a/apps/desktop/src/components/settings/ai/stt/shared.tsx +++ b/apps/desktop/src/components/settings/ai/stt/shared.tsx @@ -1,6 +1,7 @@ import { Icon } from "@iconify-icon/react"; import { Fireworks, Groq } from "@lobehub/icons"; import { queryOptions } from "@tanstack/react-query"; +import type { ReactNode } from "react"; import { commands as localSttCommands } from "@hypr/plugin-local-stt"; import type { @@ -9,6 +10,17 @@ import type { WhisperModel, } from "@hypr/plugin-local-stt"; +type Provider = { + disabled: boolean; + id: string; + displayName: string; + icon: ReactNode; + baseUrl?: string; + models: SupportedSttModel[] | string[]; + badge?: string | null; + requiresPro?: boolean; +}; + export type ProviderId = (typeof PROVIDERS)[number]["id"]; type ProviderModels = { @@ -24,6 +36,10 @@ type LanguageSupportMap = { }; export const displayModelId = (model: string) => { + if (model === "cloud") { + return "Cloud"; + } + if (model.startsWith("am-")) { const am = model as AmModel; if (am == "am-parakeet-v2") { @@ -55,11 +71,13 @@ export const PROVIDERS = [ icon: Hyprnote, baseUrl: "https://api.hyprnote.com/v1", models: [ + "cloud", "am-parakeet-v2", "am-parakeet-v3", "QuantizedTinyEn", "QuantizedSmallEn", - ] satisfies SupportedSttModel[], + ], + requiresPro: false, }, { disabled: false, @@ -82,6 +100,7 @@ export const PROVIDERS = [ "nova-2-automotive", "nova-2-atc", ], + requiresPro: false, }, { disabled: false, @@ -91,6 +110,7 @@ export const PROVIDERS = [ icon: , baseUrl: undefined, models: [], + requiresPro: false, }, { disabled: true, @@ -100,6 +120,7 @@ export const PROVIDERS = [ icon: , baseUrl: "https://api.groq.com/v1", models: ["whisper-large-v3-turbo", "whisper-large-v3"], + requiresPro: false, }, { disabled: true, @@ -109,8 +130,13 @@ export const PROVIDERS = [ icon: , baseUrl: "https://api.firework.ai/v1", models: ["whisper-large-v3-turbo", "whisper-large-v3"], + requiresPro: false, }, -] as const; +] as const satisfies readonly Provider[]; + +export const sttProviderRequiresPro = (providerId: ProviderId) => + PROVIDERS.find((provider) => provider.id === providerId)?.requiresPro ?? + false; export const LANGUAGE_SUPPORT: LanguageSupportMap = { hyprnote: { diff --git a/apps/desktop/src/config/registry.ts b/apps/desktop/src/config/registry.ts index aa1b80f602..9a765c054f 100644 --- a/apps/desktop/src/config/registry.ts +++ b/apps/desktop/src/config/registry.ts @@ -91,13 +91,16 @@ export const CONFIG_REGISTRY = { key: "current_stt_provider", default: undefined, sideEffect: async (_value: string | undefined, getConfig) => { - const provider = getConfig("current_stt_provider"); - const model = getConfig("current_stt_model") as - | SupportedSttModel - | undefined; - - if (provider === "hyprnote" && model) { - await localSttCommands.startServer(model); + const provider = getConfig("current_stt_provider") as string | undefined; + const model = getConfig("current_stt_model") as string | undefined; + + if ( + provider === "hyprnote" && + model && + model !== "cloud" && + (model.startsWith("am-") || model.startsWith("Quantized")) + ) { + await localSttCommands.startServer(model as SupportedSttModel); } }, }, @@ -106,13 +109,16 @@ export const CONFIG_REGISTRY = { key: "current_stt_model", default: undefined, sideEffect: async (_value: string | undefined, getConfig) => { - const provider = getConfig("current_stt_provider"); - const model = getConfig("current_stt_model") as - | SupportedSttModel - | undefined; - - if (provider === "hyprnote" && model) { - await localSttCommands.startServer(model); + const provider = getConfig("current_stt_provider") as string | undefined; + const model = getConfig("current_stt_model") as string | undefined; + + if ( + provider === "hyprnote" && + model && + model !== "cloud" && + (model.startsWith("am-") || model.startsWith("Quantized")) + ) { + await localSttCommands.startServer(model as SupportedSttModel); } }, }, diff --git a/apps/desktop/src/env.ts b/apps/desktop/src/env.ts index 7fd951acfe..159620c129 100644 --- a/apps/desktop/src/env.ts +++ b/apps/desktop/src/env.ts @@ -4,7 +4,8 @@ import { z } from "zod"; export const env = createEnv({ clientPrefix: "VITE_", client: { - VITE_APP_URL: z.string().min(1).optional(), + VITE_APP_URL: z.string().min(1).default("http://localhost:3000"), + VITE_API_URL: z.string().min(1).default("http://localhost:8787"), VITE_SUPABASE_URL: z.string().min(1).optional(), VITE_SUPABASE_ANON_KEY: z.string().min(1).optional(), VITE_PRO_PRODUCT_ID: z.string().min(1).optional(), diff --git a/apps/desktop/src/hooks/useBilling.ts b/apps/desktop/src/hooks/useBilling.ts deleted file mode 100644 index 353dfb42d2..0000000000 --- a/apps/desktop/src/hooks/useBilling.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import type Stripe from "stripe"; - -import { useAuth } from "../auth"; -import { env } from "../env"; - -export function useBilling() { - const auth = useAuth(); - - return useQuery({ - enabled: !!auth?.supabase && !!auth?.session?.user?.id, - queryKey: ["billing", auth?.session?.user?.id], - queryFn: async () => { - if (!auth?.supabase || !auth?.session?.user?.id) { - return null; - } - - const { data, error } = await auth.supabase - .from("billings") - .select("*") - .eq("user_id", auth.session.user.id) - .maybeSingle(); - - if (error) { - throw error; - } - - const billing = data as { - id: string; - user_id: string; - created_at: string; - updated_at: string; - stripe_customer: Stripe.Customer | null; - stripe_subscription: Stripe.Subscription | null; - } | null; - - return { ...billing, isPro: isPro(billing?.stripe_subscription) }; - }, - }); -} - -function isPro(subscription: Stripe.Subscription | null | undefined): boolean { - if (!subscription) { - return false; - } - - const hasValidStatus = ["active", "trialing"].includes(subscription.status); - - const hasProProduct = subscription.items.data.some((item) => { - const product = item.price.product; - - return typeof product === "string" - ? product === env.VITE_PRO_PRODUCT_ID - : product.id === env.VITE_PRO_PRODUCT_ID; - }); - - return hasValidStatus && hasProProduct; -} diff --git a/apps/desktop/src/hooks/useLLMConnection.ts b/apps/desktop/src/hooks/useLLMConnection.ts index c258c1b28e..d2fe214258 100644 --- a/apps/desktop/src/hooks/useLLMConnection.ts +++ b/apps/desktop/src/hooks/useLLMConnection.ts @@ -18,43 +18,68 @@ import { import { env } from "../env"; import * as main from "../store/tinybase/main"; +type LLMConnectionInfo = { + providerId: ProviderId; + modelId: string; + baseUrl: string; + apiKey: string; +}; + +type LLMConnectionStatus = + | { status: "pending"; reason: "missing_provider" } + | { status: "pending"; reason: "missing_model"; providerId: ProviderId } + | { status: "error"; reason: "provider_not_found"; providerId: string } + | { status: "error"; reason: "unauthenticated"; providerId: "hyprnote" } + | { + status: "error"; + reason: "missing_config"; + providerId: ProviderId; + missing: Array<"base_url" | "api_key">; + } + | { status: "success"; providerId: ProviderId; isHosted: boolean }; + +type LLMConnectionResult = { + conn: LLMConnectionInfo | null; + status: LLMConnectionStatus; +}; + export const useLanguageModel = (): Exclude | null => { - const connection = useLLMConnection(); + const { conn } = useLLMConnection(); return useMemo(() => { - if (!connection) { + if (!conn) { return null; } - if (connection.providerId === "hyprnote") { + if (conn.providerId === "hyprnote") { const hyprnoteProvider = createOpenAICompatible({ fetch: tauriFetch, name: "hyprnote", - baseURL: connection.baseUrl, - apiKey: connection.apiKey, + baseURL: conn.baseUrl, + apiKey: conn.apiKey, headers: { - Authorization: `Bearer ${connection.apiKey}`, + Authorization: `Bearer ${conn.apiKey}`, }, }); return wrapWithThinkingMiddleware( - hyprnoteProvider.chatModel(connection.modelId), + hyprnoteProvider.chatModel(conn.modelId), ); } - if (connection.providerId === "anthropic") { + if (conn.providerId === "anthropic") { const anthropicProvider = createAnthropic({ fetch: tauriFetch, - apiKey: connection.apiKey, + apiKey: conn.apiKey, }); - return wrapWithThinkingMiddleware(anthropicProvider(connection.modelId)); + return wrapWithThinkingMiddleware(anthropicProvider(conn.modelId)); } - if (connection.providerId === "openrouter") { + if (conn.providerId === "openrouter") { const openRouterProvider = createOpenRouter({ fetch: tauriFetch, - apiKey: connection.apiKey, + apiKey: conn.apiKey, extraBody: { provider: { // https://openrouter.ai/docs/features/provider-routing#provider-sorting @@ -63,42 +88,37 @@ export const useLanguageModel = (): Exclude | null => { }, }); - return wrapWithThinkingMiddleware(openRouterProvider(connection.modelId)); + return wrapWithThinkingMiddleware(openRouterProvider(conn.modelId)); } - if (connection.providerId === "openai") { + if (conn.providerId === "openai") { const openAIProvider = createOpenAI({ fetch: tauriFetch, - apiKey: connection.apiKey, + apiKey: conn.apiKey, }); - return wrapWithThinkingMiddleware(openAIProvider(connection.modelId)); + return wrapWithThinkingMiddleware(openAIProvider(conn.modelId)); } const config: Parameters[0] = { fetch: tauriFetch, - name: connection.providerId, - baseURL: connection.baseUrl, + name: conn.providerId, + baseURL: conn.baseUrl, }; - if (connection.apiKey) { - config.apiKey = connection.apiKey; + if (conn.apiKey) { + config.apiKey = conn.apiKey; } const openAICompatibleProvider = createOpenAICompatible(config); return wrapWithThinkingMiddleware( - openAICompatibleProvider.chatModel(connection.modelId), + openAICompatibleProvider.chatModel(conn.modelId), ); - }, [connection]); + }, [conn]); }; -const useLLMConnection = (): { - providerId: ProviderId; - modelId: string; - baseUrl: string; - apiKey: string; -} | null => { +export const useLLMConnection = (): LLMConnectionResult => { const auth = useAuth(); const { current_llm_provider, current_llm_model } = main.UI.useValues( @@ -110,51 +130,103 @@ const useLLMConnection = (): { main.STORE_ID, ) as main.AIProviderStorage | undefined; - return useMemo(() => { - if (!current_llm_provider || !current_llm_model) { - return null; + return useMemo(() => { + if (!current_llm_provider) { + return { + conn: null, + status: { status: "pending", reason: "missing_provider" }, + }; } const providerId = current_llm_provider as ProviderId; + + if (!current_llm_model) { + return { + conn: null, + status: { + status: "pending", + reason: "missing_model", + providerId, + }, + }; + } + const providerDefinition = PROVIDERS.find( - (provider) => provider.id === providerId, + (provider) => provider.id === current_llm_provider, ); + if (!providerDefinition) { + return { + conn: null, + status: { + status: "error", + reason: "provider_not_found", + providerId: current_llm_provider, + }, + }; + } + if (providerId === "hyprnote") { - if (!auth?.session || !env.VITE_SUPABASE_URL) { - return null; + if (!auth?.session) { + return { + conn: null, + status: { status: "error", reason: "unauthenticated", providerId }, + }; } - const baseUrl = `${env.VITE_SUPABASE_URL}${providerDefinition?.baseUrl || ""}`; - const apiKey = auth.session.access_token; - - return { + const conn: LLMConnectionInfo = { providerId, modelId: current_llm_model, - baseUrl, - apiKey, + baseUrl: `${env.VITE_API_URL}`, + apiKey: auth.session.access_token, + }; + + return { + conn, + status: { status: "success", providerId, isHosted: true }, }; } const baseUrl = - providerConfig?.base_url?.trim() || providerDefinition?.baseUrl || ""; + providerConfig?.base_url?.trim() || + providerDefinition.baseUrl?.trim() || + ""; const apiKey = providerConfig?.api_key?.trim() || ""; + const missing: Array<"base_url" | "api_key"> = []; + if (!baseUrl) { - return null; + missing.push("base_url"); } - if ((providerDefinition?.apiKey ?? true) && !apiKey) { - return null; + if (providerDefinition.apiKey && !apiKey) { + missing.push("api_key"); } - return { + if (missing.length > 0) { + return { + conn: null, + status: { + status: "error", + reason: "missing_config", + providerId, + missing, + }, + }; + } + + const conn: LLMConnectionInfo = { providerId, modelId: current_llm_model, baseUrl, apiKey, }; - }, [current_llm_provider, current_llm_model, providerConfig, auth]); + + return { + conn, + status: { status: "success", providerId, isHosted: false }, + }; + }, [auth, current_llm_model, current_llm_provider, providerConfig]); }; const wrapWithThinkingMiddleware = (model: Exclude) => { diff --git a/apps/desktop/src/hooks/useSTTConnection.ts b/apps/desktop/src/hooks/useSTTConnection.ts index 9a2e0d0ed9..99c795a4cd 100644 --- a/apps/desktop/src/hooks/useSTTConnection.ts +++ b/apps/desktop/src/hooks/useSTTConnection.ts @@ -3,10 +3,13 @@ import { useMemo } from "react"; import { commands as localSttCommands } from "@hypr/plugin-local-stt"; +import { useAuth } from "../auth"; import { ProviderId } from "../components/settings/ai/stt/shared"; +import { env } from "../env"; import * as main from "../store/tinybase/main"; export const useSTTConnection = () => { + const auth = useAuth(); const { current_stt_provider, current_stt_model } = main.UI.useValues( main.STORE_ID, ) as { @@ -26,6 +29,9 @@ export const useSTTConnection = () => { (current_stt_model.startsWith("am-") || current_stt_model.startsWith("Quantized")); + const isCloudModel = + current_stt_provider === "hyprnote" && current_stt_model === "cloud"; + const local = useQuery({ enabled: current_stt_provider === "hyprnote", queryKey: ["stt-connection", isLocalModel, current_stt_model], @@ -77,6 +83,19 @@ export const useSTTConnection = () => { return local.data?.connection ?? null; } + if (isCloudModel) { + if (!auth?.session) { + return null; + } + + return { + provider: current_stt_provider, + model: current_stt_model, + baseUrl: `${env.VITE_API_URL}`, + apiKey: auth.session.access_token, + }; + } + if (!baseUrl || !apiKey) { return null; } @@ -91,9 +110,11 @@ export const useSTTConnection = () => { current_stt_provider, current_stt_model, isLocalModel, + isCloudModel, local.data, baseUrl, apiKey, + auth, ]); return { diff --git a/apps/desktop/src/routes/__root.tsx b/apps/desktop/src/routes/__root.tsx index 16a424bc90..c5aecc5f91 100644 --- a/apps/desktop/src/routes/__root.tsx +++ b/apps/desktop/src/routes/__root.tsx @@ -11,6 +11,7 @@ import { lazy, Suspense, useEffect } from "react"; import { events as windowsEvents } from "@hypr/plugin-windows"; import { AuthProvider } from "../auth"; +import { BillingProvider } from "../billing"; import { ErrorComponent, NotFoundComponent } from "../components/control"; import type { Context } from "../types"; @@ -25,10 +26,12 @@ function Component() { return ( - - - - + + + + + + ); } diff --git a/apps/web/.env.sample b/apps/web/.env.sample index eae3647fca..d6d0834767 100644 --- a/apps/web/.env.sample +++ b/apps/web/.env.sample @@ -11,8 +11,6 @@ STRIPE_YEARLY_PRICE_ID="" STRIPE_SECRET_KEY="" STRIPE_WEBHOOK_SECRET="" -VITE_STRIPE_PUBLISHABLE_KEY="" - VITE_POSTHOG_API_KEY="" VITE_POSTHOG_HOST="" diff --git a/apps/web/package.json b/apps/web/package.json index e40b1f266d..1263c0243c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "dev": "env-cmd -f ./.env -- vite dev --port 3000", + "dev": "env-cmd --silent -f ./.env -- vite dev --port 3000", "build": "vite build", "serve": "vite preview", "typecheck": "pnpm -F @hypr/web build && tsc --noEmit" diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index 69bd5c4a33..677aa82027 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -22,7 +22,6 @@ export const env = createEnv({ VITE_APP_URL: z.string().min(1), VITE_SUPABASE_URL: z.string().min(1), VITE_SUPABASE_ANON_KEY: z.string().min(1), - VITE_STRIPE_PUBLISHABLE_KEY: z.string().min(1), VITE_POSTHOG_API_KEY: z.string().min(1), VITE_POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), }, diff --git a/apps/web/src/functions/auth.ts b/apps/web/src/functions/auth.ts index b0c206d329..c6e48d1b3c 100644 --- a/apps/web/src/functions/auth.ts +++ b/apps/web/src/functions/auth.ts @@ -1,8 +1,9 @@ -import { env } from "@/env"; -import { getSupabaseServerClient } from "@/functions/supabase"; import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; +import { env } from "@/env"; +import { getSupabaseServerClient } from "@/functions/supabase"; + const shared = z.object({ flow: z.enum(["desktop", "web"]).default("desktop"), }); diff --git a/apps/web/src/functions/billing.ts b/apps/web/src/functions/billing.ts index d0ba351922..a551a4980e 100644 --- a/apps/web/src/functions/billing.ts +++ b/apps/web/src/functions/billing.ts @@ -1,10 +1,17 @@ +import { createServerFn } from "@tanstack/react-start"; +import { z } from "zod"; + import { env } from "@/env"; import { getStripeClient } from "@/functions/stripe"; import { getSupabaseServerClient } from "@/functions/supabase"; -import { createServerFn } from "@tanstack/react-start"; -export const createCheckoutSession = createServerFn({ method: "POST" }).handler( - async () => { +const createCheckoutSessionInput = z.object({ + period: z.enum(["monthly", "yearly"]), +}); + +export const createCheckoutSession = createServerFn({ method: "POST" }) + .inputValidator(createCheckoutSessionInput) + .handler(async ({ data }) => { const supabase = getSupabaseServerClient(); const { data: { user }, @@ -37,13 +44,18 @@ export const createCheckoutSession = createServerFn({ method: "POST" }).handler( stripeCustomerId = newCustomer.id; } + const priceId = + data.period === "yearly" + ? env.STRIPE_YEARLY_PRICE_ID + : env.STRIPE_MONTHLY_PRICE_ID; + const checkout = await stripe.checkout.sessions.create({ customer: stripeCustomerId, success_url: `${env.VITE_APP_URL}/app/account?success=true`, cancel_url: `${env.VITE_APP_URL}/app/account`, line_items: [ { - price: env.STRIPE_MONTHLY_PRICE_ID, + price: priceId, quantity: 1, }, ], @@ -51,8 +63,7 @@ export const createCheckoutSession = createServerFn({ method: "POST" }).handler( }); return { url: checkout.url }; - }, -); + }); export const createPortalSession = createServerFn({ method: "POST" }).handler( async () => { diff --git a/apps/web/src/functions/nango.ts b/apps/web/src/functions/nango.ts index 543b8735e3..43e1e2e202 100644 --- a/apps/web/src/functions/nango.ts +++ b/apps/web/src/functions/nango.ts @@ -1,7 +1,8 @@ -import { nangoMiddleware } from "@/middleware/nango"; import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; +import { nangoMiddleware } from "@/middleware/nango"; + const CreateConnectSessionInput = z.object({ userId: z.string().min(1), userEmail: z.string().email().optional(), diff --git a/apps/web/src/functions/stripe.ts b/apps/web/src/functions/stripe.ts index 8bbfa68543..1eb622ab2a 100644 --- a/apps/web/src/functions/stripe.ts +++ b/apps/web/src/functions/stripe.ts @@ -1,7 +1,8 @@ -import { env } from "@/env"; import { createServerOnlyFn } from "@tanstack/react-start"; import Stripe from "stripe"; +import { env } from "@/env"; + export const getStripeClient = createServerOnlyFn(() => { return new Stripe(env.STRIPE_SECRET_KEY, { apiVersion: "2025-10-29.clover", diff --git a/apps/web/src/functions/supabase.ts b/apps/web/src/functions/supabase.ts index c6dcaebad3..d3018330dd 100644 --- a/apps/web/src/functions/supabase.ts +++ b/apps/web/src/functions/supabase.ts @@ -1,8 +1,9 @@ -import { env } from "@/env"; import { createBrowserClient, createServerClient } from "@supabase/ssr"; import { createClientOnlyFn, createServerOnlyFn } from "@tanstack/react-start"; import { getCookies, setCookie } from "@tanstack/react-start/server"; +import { env } from "@/env"; + export const getSupabaseBrowserClient = createClientOnlyFn(() => { return createBrowserClient( env.VITE_SUPABASE_URL, diff --git a/apps/web/src/middleware/drizzle.ts b/apps/web/src/middleware/drizzle.ts index e96738833f..a27c2a8dac 100644 --- a/apps/web/src/middleware/drizzle.ts +++ b/apps/web/src/middleware/drizzle.ts @@ -1,8 +1,9 @@ -import { env } from "@/env"; import { createMiddleware } from "@tanstack/react-start"; import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; +import { env } from "@/env"; + export const drizzleMiddleware = createMiddleware().server(async ({ next }) => { const client = postgres(env.DATABASE_URL, { prepare: false }); const db = drizzle({ client }); diff --git a/apps/web/src/middleware/nango.ts b/apps/web/src/middleware/nango.ts index 5bbb07e212..fd0cdab6b0 100644 --- a/apps/web/src/middleware/nango.ts +++ b/apps/web/src/middleware/nango.ts @@ -1,7 +1,8 @@ -import { env } from "@/env"; import { Nango } from "@nangohq/node"; import { createMiddleware } from "@tanstack/react-start"; +import { env } from "@/env"; + export const nangoMiddleware = createMiddleware().server(async ({ next }) => { const nango = new Nango({ secretKey: env.NANGO_SECRET_KEY }); return next({ context: { nango } }); diff --git a/apps/web/src/middleware/supabase.ts b/apps/web/src/middleware/supabase.ts index 01f3b214e2..d1b7fd02ce 100644 --- a/apps/web/src/middleware/supabase.ts +++ b/apps/web/src/middleware/supabase.ts @@ -1,6 +1,7 @@ -import { getSupabaseServerClient } from "@/functions/supabase"; import { createMiddleware } from "@tanstack/react-start"; +import { getSupabaseServerClient } from "@/functions/supabase"; + export const supabaseClientMiddleware = createMiddleware().server( async ({ next }) => { const supabase = getSupabaseServerClient(); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 18848b79c0..119c605b40 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -55,6 +55,7 @@ import { Route as ViewChangelogSlugRouteImport } from './routes/_view/changelog/ import { Route as ViewCallbackAuthRouteImport } from './routes/_view/callback/auth' import { Route as ViewBlogSlugRouteImport } from './routes/_view/blog/$slug' import { Route as ViewAppIntegrationRouteImport } from './routes/_view/app/integration' +import { Route as ViewAppCheckoutRouteImport } from './routes/_view/app/checkout' import { Route as ViewAppAccountRouteImport } from './routes/_view/app/account' const YoutubeRoute = YoutubeRouteImport.update({ @@ -287,6 +288,11 @@ const ViewAppIntegrationRoute = ViewAppIntegrationRouteImport.update({ path: '/integration', getParentRoute: () => ViewAppRouteRoute, } as any) +const ViewAppCheckoutRoute = ViewAppCheckoutRouteImport.update({ + id: '/checkout', + path: '/checkout', + getParentRoute: () => ViewAppRouteRoute, +} as any) const ViewAppAccountRoute = ViewAppAccountRouteImport.update({ id: '/account', path: '/account', @@ -317,6 +323,7 @@ export interface FileRoutesByFullPath { '/webhook/stripe': typeof WebhookStripeRoute '/': typeof ViewIndexRoute '/app/account': typeof ViewAppAccountRoute + '/app/checkout': typeof ViewAppCheckoutRoute '/app/integration': typeof ViewAppIntegrationRoute '/blog/$slug': typeof ViewBlogSlugRoute '/callback/auth': typeof ViewCallbackAuthRoute @@ -363,6 +370,7 @@ export interface FileRoutesByTo { '/webhook/stripe': typeof WebhookStripeRoute '/': typeof ViewIndexRoute '/app/account': typeof ViewAppAccountRoute + '/app/checkout': typeof ViewAppCheckoutRoute '/app/integration': typeof ViewAppIntegrationRoute '/blog/$slug': typeof ViewBlogSlugRoute '/callback/auth': typeof ViewCallbackAuthRoute @@ -413,6 +421,7 @@ export interface FileRoutesById { '/webhook/stripe': typeof WebhookStripeRoute '/_view/': typeof ViewIndexRoute '/_view/app/account': typeof ViewAppAccountRoute + '/_view/app/checkout': typeof ViewAppCheckoutRoute '/_view/app/integration': typeof ViewAppIntegrationRoute '/_view/blog/$slug': typeof ViewBlogSlugRoute '/_view/callback/auth': typeof ViewCallbackAuthRoute @@ -463,6 +472,7 @@ export interface FileRouteTypes { | '/webhook/stripe' | '/' | '/app/account' + | '/app/checkout' | '/app/integration' | '/blog/$slug' | '/callback/auth' @@ -509,6 +519,7 @@ export interface FileRouteTypes { | '/webhook/stripe' | '/' | '/app/account' + | '/app/checkout' | '/app/integration' | '/blog/$slug' | '/callback/auth' @@ -558,6 +569,7 @@ export interface FileRouteTypes { | '/webhook/stripe' | '/_view/' | '/_view/app/account' + | '/_view/app/checkout' | '/_view/app/integration' | '/_view/blog/$slug' | '/_view/callback/auth' @@ -923,6 +935,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ViewAppIntegrationRouteImport parentRoute: typeof ViewAppRouteRoute } + '/_view/app/checkout': { + id: '/_view/app/checkout' + path: '/checkout' + fullPath: '/app/checkout' + preLoaderRoute: typeof ViewAppCheckoutRouteImport + parentRoute: typeof ViewAppRouteRoute + } '/_view/app/account': { id: '/_view/app/account' path: '/account' @@ -935,12 +954,14 @@ declare module '@tanstack/react-router' { interface ViewAppRouteRouteChildren { ViewAppAccountRoute: typeof ViewAppAccountRoute + ViewAppCheckoutRoute: typeof ViewAppCheckoutRoute ViewAppIntegrationRoute: typeof ViewAppIntegrationRoute ViewAppIndexRoute: typeof ViewAppIndexRoute } const ViewAppRouteRouteChildren: ViewAppRouteRouteChildren = { ViewAppAccountRoute: ViewAppAccountRoute, + ViewAppCheckoutRoute: ViewAppCheckoutRoute, ViewAppIntegrationRoute: ViewAppIntegrationRoute, ViewAppIndexRoute: ViewAppIndexRoute, } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 29bcdcad11..26c28833db 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,6 +1,3 @@ -import { NotFoundDocument } from "@/components/not-found"; -import { fetchUser } from "@/functions/auth"; -import appCss from "@/styles.css?url"; import type { QueryClient } from "@tanstack/react-query"; import { createRootRouteWithContext, @@ -9,6 +6,10 @@ import { } from "@tanstack/react-router"; import { lazy } from "react"; +import { NotFoundDocument } from "@/components/not-found"; +import { fetchUser } from "@/functions/auth"; +import appCss from "@/styles.css?url"; + interface RouterContext { queryClient: QueryClient; } diff --git a/apps/web/src/routes/_view/app/account.tsx b/apps/web/src/routes/_view/app/account.tsx index 26171f259e..39ec83c5a0 100644 --- a/apps/web/src/routes/_view/app/account.tsx +++ b/apps/web/src/routes/_view/app/account.tsx @@ -1,12 +1,13 @@ -import { signOutFn } from "@/functions/auth"; -import { createPortalSession } from "@/functions/billing"; -import { addContact } from "@/functions/loops"; -import { useAnalytics } from "@/hooks/use-posthog"; import { useForm } from "@tanstack/react-form"; import { useMutation } from "@tanstack/react-query"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; +import { signOutFn } from "@/functions/auth"; +import { createPortalSession } from "@/functions/billing"; +import { addContact } from "@/functions/loops"; +import { useAnalytics } from "@/hooks/use-posthog"; + export const Route = createFileRoute("/_view/app/account")({ component: Component, loader: async ({ context }) => ({ user: context.user }), @@ -57,18 +58,6 @@ function AccountSettingsCard() { "free", ); - const startTrialMutation = useMutation({ - mutationFn: async () => { - console.log("Starting trial..."); - }, - }); - - const startProTrialMutation = useMutation({ - mutationFn: async () => { - console.log("Starting pro trial..."); - }, - }); - const manageBillingMutation = useMutation({ mutationFn: async () => { const { url } = await createPortalSession(); @@ -81,13 +70,13 @@ function AccountSettingsCard() { const renderPlanButton = () => { if (currentPlan === "free") { return ( - + Upgrade to Pro + ); } @@ -106,18 +95,12 @@ function AccountSettingsCard() { if (currentPlan === "trial_over") { return (
- - Upgrade + Upgrade to Pro
); diff --git a/apps/web/src/routes/_view/app/checkout.tsx b/apps/web/src/routes/_view/app/checkout.tsx new file mode 100644 index 0000000000..de98709497 --- /dev/null +++ b/apps/web/src/routes/_view/app/checkout.tsx @@ -0,0 +1,23 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { z } from "zod"; + +import { createCheckoutSession } from "@/functions/billing"; + +const validateSearch = z.object({ + period: z.enum(["monthly", "yearly"]).default("monthly"), +}); + +export const Route = createFileRoute("/_view/app/checkout")({ + validateSearch, + beforeLoad: async ({ search }) => { + const { url } = await createCheckoutSession({ + data: { period: search.period }, + }); + + if (url) { + throw redirect({ href: url }); + } + + throw redirect({ to: "/app/account" }); + }, +}); diff --git a/apps/web/src/routes/_view/blog/$slug.tsx b/apps/web/src/routes/_view/blog/$slug.tsx index fede1fce33..abb8587810 100644 --- a/apps/web/src/routes/_view/blog/$slug.tsx +++ b/apps/web/src/routes/_view/blog/$slug.tsx @@ -1,5 +1,3 @@ -import { CtaCard } from "@/components/cta-card"; -import { Image } from "@/components/image"; import { MDXContent } from "@content-collections/mdx/react"; import { Icon } from "@iconify-icon/react"; import { createFileRoute, Link, notFound } from "@tanstack/react-router"; @@ -8,6 +6,9 @@ import { useState } from "react"; import { cn } from "@hypr/utils"; +import { CtaCard } from "@/components/cta-card"; +import { Image } from "@/components/image"; + export const Route = createFileRoute("/_view/blog/$slug")({ component: Component, loader: async ({ params }) => { diff --git a/apps/web/src/routes/_view/callback/auth.tsx b/apps/web/src/routes/_view/callback/auth.tsx index ddacad9977..a2c77658e6 100644 --- a/apps/web/src/routes/_view/callback/auth.tsx +++ b/apps/web/src/routes/_view/callback/auth.tsx @@ -1,8 +1,9 @@ -import { getSupabaseServerClient } from "@/functions/supabase"; import { createFileRoute, redirect } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { z } from "zod"; +import { getSupabaseServerClient } from "@/functions/supabase"; + const validateSearch = z.object({ code: z.string().optional(), flow: z.enum(["desktop", "web"]).default("desktop"), diff --git a/apps/web/src/routes/_view/docs/-components.tsx b/apps/web/src/routes/_view/docs/-components.tsx index 4ac754453a..425d140fbb 100644 --- a/apps/web/src/routes/_view/docs/-components.tsx +++ b/apps/web/src/routes/_view/docs/-components.tsx @@ -1,4 +1,3 @@ -import { CtaCard } from "@/components/cta-card"; import { MDXContent } from "@content-collections/mdx/react"; import { Icon } from "@iconify-icon/react"; import { Link } from "@tanstack/react-router"; @@ -16,6 +15,8 @@ import { } from "@hypr/ui/docs"; import { cn } from "@hypr/utils"; +import { CtaCard } from "@/components/cta-card"; + export function DocLayout({ doc, showSectionTitle = true, diff --git a/apps/web/src/routes/_view/download/index.tsx b/apps/web/src/routes/_view/download/index.tsx index e81149119f..d9543a9c89 100644 --- a/apps/web/src/routes/_view/download/index.tsx +++ b/apps/web/src/routes/_view/download/index.tsx @@ -1,10 +1,11 @@ -import { Image } from "@/components/image"; -import { SlashSeparator } from "@/components/slash-separator"; import { Icon } from "@iconify-icon/react"; import { createFileRoute, Link } from "@tanstack/react-router"; import { cn } from "@hypr/utils"; +import { Image } from "@/components/image"; +import { SlashSeparator } from "@/components/slash-separator"; + export const Route = createFileRoute("/_view/download/")({ component: Component, }); diff --git a/apps/web/src/routes/_view/enterprise.tsx b/apps/web/src/routes/_view/enterprise.tsx index 0392ad767a..b77e58b326 100644 --- a/apps/web/src/routes/_view/enterprise.tsx +++ b/apps/web/src/routes/_view/enterprise.tsx @@ -1,9 +1,10 @@ -import { SlashSeparator } from "@/components/slash-separator"; import { Icon } from "@iconify-icon/react"; import { createFileRoute, Link } from "@tanstack/react-router"; import { cn } from "@hypr/utils"; +import { SlashSeparator } from "@/components/slash-separator"; + export const Route = createFileRoute("/_view/enterprise")({ component: Component, head: () => ({ diff --git a/apps/web/src/routes/_view/index.tsx b/apps/web/src/routes/_view/index.tsx index 46457f436e..aba28619d7 100644 --- a/apps/web/src/routes/_view/index.tsx +++ b/apps/web/src/routes/_view/index.tsx @@ -1,3 +1,12 @@ +import { Icon } from "@iconify-icon/react"; +import { useForm } from "@tanstack/react-form"; +import { useMutation } from "@tanstack/react-query"; +import { createFileRoute, Link } from "@tanstack/react-router"; +import { allArticles } from "content-collections"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { cn } from "@hypr/utils"; + import { DownloadButton } from "@/components/download-button"; import { GitHubOpenSource } from "@/components/github-open-source"; import { GithubStars } from "@/components/github-stars"; @@ -11,14 +20,6 @@ import { VideoThumbnail } from "@/components/video-thumbnail"; import { addContact } from "@/functions/loops"; import { getHeroCTA, getPlatformCTA, usePlatform } from "@/hooks/use-platform"; import { useAnalytics } from "@/hooks/use-posthog"; -import { Icon } from "@iconify-icon/react"; -import { useForm } from "@tanstack/react-form"; -import { useMutation } from "@tanstack/react-query"; -import { createFileRoute, Link } from "@tanstack/react-router"; -import { allArticles } from "content-collections"; -import { useCallback, useEffect, useRef, useState } from "react"; - -import { cn } from "@hypr/utils"; import { useHeroContext } from "./route"; diff --git a/apps/web/src/routes/_view/pricing.tsx b/apps/web/src/routes/_view/pricing.tsx index e5dcd766d8..7365d699b7 100644 --- a/apps/web/src/routes/_view/pricing.tsx +++ b/apps/web/src/routes/_view/pricing.tsx @@ -1,10 +1,11 @@ -import { Image } from "@/components/image"; -import { SlashSeparator } from "@/components/slash-separator"; import { Icon } from "@iconify-icon/react"; import { createFileRoute, Link } from "@tanstack/react-router"; import { cn } from "@hypr/utils"; +import { Image } from "@/components/image"; +import { SlashSeparator } from "@/components/slash-separator"; + export const Route = createFileRoute("/_view/pricing")({ component: Component, }); diff --git a/apps/web/src/routes/_view/product/ai-assistant.tsx b/apps/web/src/routes/_view/product/ai-assistant.tsx index a3fc2369c9..2b840823e4 100644 --- a/apps/web/src/routes/_view/product/ai-assistant.tsx +++ b/apps/web/src/routes/_view/product/ai-assistant.tsx @@ -1,9 +1,10 @@ -import { SlashSeparator } from "@/components/slash-separator"; import { Icon } from "@iconify-icon/react"; import { createFileRoute, Link } from "@tanstack/react-router"; import { cn } from "@hypr/utils"; +import { SlashSeparator } from "@/components/slash-separator"; + export const Route = createFileRoute("/_view/product/ai-assistant")({ component: Component, head: () => ({ diff --git a/apps/web/src/routes/_view/product/ai-notetaking.tsx b/apps/web/src/routes/_view/product/ai-notetaking.tsx index 38e0ed1dcb..7500fff8ae 100644 --- a/apps/web/src/routes/_view/product/ai-notetaking.tsx +++ b/apps/web/src/routes/_view/product/ai-notetaking.tsx @@ -1,11 +1,12 @@ -import { MockWindow } from "@/components/mock-window"; -import { SlashSeparator } from "@/components/slash-separator"; import { Icon } from "@iconify-icon/react"; import { createFileRoute } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { cn } from "@hypr/utils"; +import { MockWindow } from "@/components/mock-window"; +import { SlashSeparator } from "@/components/slash-separator"; + export const Route = createFileRoute("/_view/product/ai-notetaking")({ component: Component, head: () => ({ diff --git a/apps/web/src/routes/_view/product/api.tsx b/apps/web/src/routes/_view/product/api.tsx index 5e4249c393..b2797d1bd9 100644 --- a/apps/web/src/routes/_view/product/api.tsx +++ b/apps/web/src/routes/_view/product/api.tsx @@ -1,8 +1,9 @@ -import { MockWindow } from "@/components/mock-window"; import { createFileRoute } from "@tanstack/react-router"; import { cn } from "@hypr/utils"; +import { MockWindow } from "@/components/mock-window"; + export const Route = createFileRoute("/_view/product/api")({ component: Component, head: () => ({ diff --git a/apps/web/src/routes/_view/product/mini-apps.tsx b/apps/web/src/routes/_view/product/mini-apps.tsx index f93fe28ee3..898b15873e 100644 --- a/apps/web/src/routes/_view/product/mini-apps.tsx +++ b/apps/web/src/routes/_view/product/mini-apps.tsx @@ -1,9 +1,10 @@ -import { SlashSeparator } from "@/components/slash-separator"; import { Icon } from "@iconify-icon/react"; import { createFileRoute } from "@tanstack/react-router"; import { cn } from "@hypr/utils"; +import { SlashSeparator } from "@/components/slash-separator"; + export const Route = createFileRoute("/_view/product/mini-apps")({ component: Component, head: () => ({ diff --git a/apps/web/src/routes/_view/product/notepad.tsx b/apps/web/src/routes/_view/product/notepad.tsx index 3b1caba937..3251571337 100644 --- a/apps/web/src/routes/_view/product/notepad.tsx +++ b/apps/web/src/routes/_view/product/notepad.tsx @@ -1,9 +1,10 @@ -import { SlashSeparator } from "@/components/slash-separator"; import { Icon } from "@iconify-icon/react"; import { createFileRoute, Link } from "@tanstack/react-router"; import { cn } from "@hypr/utils"; +import { SlashSeparator } from "@/components/slash-separator"; + export const Route = createFileRoute("/_view/product/notepad")({ component: Component, head: () => ({ diff --git a/apps/web/src/routes/_view/product/workflows.tsx b/apps/web/src/routes/_view/product/workflows.tsx index 939b28bf3d..cb13666f04 100644 --- a/apps/web/src/routes/_view/product/workflows.tsx +++ b/apps/web/src/routes/_view/product/workflows.tsx @@ -1,9 +1,10 @@ -import { Image } from "@/components/image"; import { createFileRoute } from "@tanstack/react-router"; import { useCallback, useEffect, useRef, useState } from "react"; import { cn } from "@hypr/utils"; +import { Image } from "@/components/image"; + export const Route = createFileRoute("/_view/product/workflows")({ component: Component, head: () => ({ diff --git a/apps/web/src/routes/_view/route.tsx b/apps/web/src/routes/_view/route.tsx index a1cb45c400..1744ee0721 100644 --- a/apps/web/src/routes/_view/route.tsx +++ b/apps/web/src/routes/_view/route.tsx @@ -1,4 +1,3 @@ -import { getPlatformCTA, usePlatform } from "@/hooks/use-platform"; import { Icon } from "@iconify-icon/react"; import { createFileRoute, @@ -8,6 +7,8 @@ import { } from "@tanstack/react-router"; import { createContext, useContext, useState } from "react"; +import { getPlatformCTA, usePlatform } from "@/hooks/use-platform"; + export const Route = createFileRoute("/_view")({ component: Component, loader: async ({ context }) => ({ user: context.user }), diff --git a/apps/web/src/routes/_view/templates.tsx b/apps/web/src/routes/_view/templates.tsx index b674c40d1a..fe56c31ac6 100644 --- a/apps/web/src/routes/_view/templates.tsx +++ b/apps/web/src/routes/_view/templates.tsx @@ -1,5 +1,3 @@ -import { DownloadButton } from "@/components/download-button"; -import { SlashSeparator } from "@/components/slash-separator"; import { Icon } from "@iconify-icon/react"; import { createFileRoute } from "@tanstack/react-router"; import { allTemplates } from "content-collections"; @@ -7,6 +5,9 @@ import { useMemo, useState } from "react"; import { cn } from "@hypr/utils"; +import { DownloadButton } from "@/components/download-button"; +import { SlashSeparator } from "@/components/slash-separator"; + export const Route = createFileRoute("/_view/templates")({ component: Component, head: () => ({ diff --git a/apps/web/src/routes/auth.tsx b/apps/web/src/routes/auth.tsx index fb2ed3c47d..8c2d9d2b18 100644 --- a/apps/web/src/routes/auth.tsx +++ b/apps/web/src/routes/auth.tsx @@ -1,5 +1,3 @@ -import { Image } from "@/components/image"; -import { doAuth } from "@/functions/auth"; import { Icon } from "@iconify-icon/react"; import { useForm } from "@tanstack/react-form"; import { useMutation } from "@tanstack/react-query"; @@ -8,6 +6,9 @@ import { z } from "zod"; import { cn } from "@hypr/utils"; +import { Image } from "@/components/image"; +import { doAuth } from "@/functions/auth"; + const validateSearch = z.object({ flow: z.enum(["desktop", "web"]).default("web"), }); diff --git a/apps/web/src/routes/webhook/stripe.ts b/apps/web/src/routes/webhook/stripe.ts index 9787b5a9bf..7297db765e 100644 --- a/apps/web/src/routes/webhook/stripe.ts +++ b/apps/web/src/routes/webhook/stripe.ts @@ -1,8 +1,9 @@ -import { env } from "@/env"; -import { getStripeClient } from "@/functions/stripe"; import { createFileRoute } from "@tanstack/react-router"; import Stripe from "stripe"; +import { env } from "@/env"; +import { getStripeClient } from "@/functions/stripe"; + const ALLOWED_EVENTS: Stripe.Event.Type[] = [ "checkout.session.completed", "customer.subscription.created",