diff --git a/web/drizzle/meta/_journal.json b/web/drizzle/meta/_journal.json index 3fbae3d..69f5c17 100644 --- a/web/drizzle/meta/_journal.json +++ b/web/drizzle/meta/_journal.json @@ -248,4 +248,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/web/package.json b/web/package.json index faf351d..c5625d3 100644 --- a/web/package.json +++ b/web/package.json @@ -26,6 +26,7 @@ "@hono/zod-openapi": "^0.9.5", "@hono/zod-validator": "^0.1.11", "@hookform/resolvers": "^3.3.2", + "@lemonsqueezy/lemonsqueezy.js": "^1.2.5", "@mdx-js/loader": "^3.0.0", "@mdx-js/react": "^3.0.0", "@neondatabase/serverless": "^0.6.0", diff --git a/web/src/app/(app)/api/update-run/route.ts b/web/src/app/(app)/api/update-run/route.ts index e712eb1..e4b98db 100644 --- a/web/src/app/(app)/api/update-run/route.ts +++ b/web/src/app/(app)/api/update-run/route.ts @@ -1,6 +1,13 @@ import { parseDataSafe } from "../../../../lib/parseDataSafe"; import { db } from "@/db/db"; -import { workflowRunOutputs, workflowRunsTable } from "@/db/schema"; +import { + userUsageTable, + workflowRunOutputs, + workflowRunsTable, + workflowTable, +} from "@/db/schema"; +import { getDuration } from "@/lib/getRelativeTime"; +import { getSubscription, setUsage } from "@/server/linkToPricing"; import { eq } from "drizzle-orm"; import { NextResponse } from "next/server"; import { z } from "zod"; @@ -27,7 +34,6 @@ export async function POST(request: Request) { data: output_data, }); } else if (status) { - // console.log("status", status); const workflow_run = await db .update(workflowRunsTable) .set({ @@ -35,8 +41,15 @@ export async function POST(request: Request) { ended_at: status === "success" || status === "failed" ? new Date() : null, }) - .where(eq(workflowRunsTable.id, run_id)) - .returning(); + .where(eq(workflowRunsTable.id, run_id)); + + // get data from workflowRunsTable + const userUsageTime = await importUserUsageData(run_id); + + if (userUsageTime) { + // get the usage_time from userUsage + await addSubscriptionUnit(userUsageTime); + } } // const workflow_version = await db.query.workflowVersionTable.findFirst({ @@ -54,3 +67,47 @@ export async function POST(request: Request) { } ); } + +async function addSubscriptionUnit(userUsageTime: number) { + const subscription = await getSubscription(); + + // round up userUsageTime to the nearest integer + const roundedUsageTime = Math.ceil(userUsageTime); + + if (subscription) { + const usage = await setUsage( + subscription.data[0].attributes.first_subscription_item.id, + roundedUsageTime + ); + } +} + +async function importUserUsageData(run_id: string) { + const workflowRuns = await db.query.workflowRunsTable.findFirst({ + where: eq(workflowRunsTable.id, run_id), + }); + + if (!workflowRuns?.workflow_id) return; + + // find if workflowTable id column contains workflowRunsTable workflow_id + const workflow = await db.query.workflowTable.findFirst({ + where: eq(workflowTable.id, workflowRuns.workflow_id), + }); + + if (workflowRuns?.ended_at === null || workflow == null) return; + + const usageTime = parseFloat( + getDuration((workflowRuns?.ended_at - workflowRuns?.started_at) / 1000) + ); + + // add data to userUsageTable + const user_usage = await db.insert(userUsageTable).values({ + user_id: workflow.user_id, + created_at: workflowRuns.ended_at, + org_id: workflow.org_id, + ended_at: workflowRuns.ended_at, + usage_time: usageTime, + }); + + return usageTime; +} diff --git a/web/src/app/(app)/pricing/components/gpuPricingTable.tsx b/web/src/app/(app)/pricing/components/gpuPricingTable.tsx new file mode 100644 index 0000000..912f099 --- /dev/null +++ b/web/src/app/(app)/pricing/components/gpuPricingTable.tsx @@ -0,0 +1,81 @@ +const people = [ + { + name: "Nvidia T4 GPU", + gpu: "1x", + ram: "16GB", + price: "$0.000225/sec", + }, + { + name: "Nvidia A40 GPU", + gpu: "1x", + ram: "48GB", + price: "$0.000575/sec", + }, +]; + +export function GpuPricingPlan() { + return ( +
+
+ + + + + + + + + + + {people.map((person) => ( + + + + + + + ))} + +
+ GPU + + No. + + RAM + + Price +
+ {person.name} +
+
No.
+
+ {person.gpu} +
+
RAM
+
+ {person.ram} +
+
+
+ {person.gpu} + + {person.ram} + + {person.price} +
+
+
+ ); +} diff --git a/web/src/app/(app)/pricing/components/pricePlanList.tsx b/web/src/app/(app)/pricing/components/pricePlanList.tsx new file mode 100644 index 0000000..89f4358 --- /dev/null +++ b/web/src/app/(app)/pricing/components/pricePlanList.tsx @@ -0,0 +1,157 @@ +import { checkMarkIcon, crossMarkIcon } from "../const/Icon"; +import { cn } from "@/lib/utils"; +import { getPricing } from "@/server/linkToPricing"; +import { useEffect, useState } from "react"; + +type Tier = { + name: string; + id: string; + href: string; + priceMonthly: string; + description: string; + features: string[]; + featured: boolean; + priority?: TierPriority; +}; + +enum TierPriority { + Free = "free", + Pro = "pro", + Enterprise = "enterprise", +} + +export default function PricingList() { + const [productTiers, setProductTiers] = useState(); + + useEffect(() => { + (async () => { + const product = await getPricing(); + + if (!product) return; + + const newProductTiers: Tier[] = product.data.map((item) => { + // Create a new DOMParser instance + const parser = new DOMParser(); + // Parse the description HTML string to a new document + const doc = parser.parseFromString( + item.attributes.description, + "text/html" + ); + // Extract the description and features + const description = doc.querySelector("p")?.textContent || ""; + const features = Array.from(doc.querySelectorAll("ul > li")).map( + (li) => li.textContent || "" + ); + + return { + name: item.attributes.name, + id: item.id, + href: item.attributes.buy_now_url, + priceMonthly: + item.attributes.price_formatted.split("/")[0] == "Usage-based" + ? "$20.00" + : item.attributes.price_formatted.split("/")[0], + description: description, + features: features, + + // if name contains pro, it's featured + featured: item.attributes.name.toLowerCase().includes("pro"), + + // give priority if name contain in enum + priority: Object.values(TierPriority).find((priority) => + item.attributes.name.toLowerCase().includes(priority) + ), + }; + }); + + // sort newProductTiers by priority + newProductTiers.sort((a, b) => { + if (!a.priority) return 1; + if (!b.priority) return -1; + return ( + Object.values(TierPriority).indexOf(a.priority) - + Object.values(TierPriority).indexOf(b.priority) + ); + }); + + setProductTiers(newProductTiers); + })(); + }, []); + + return ( +
+
+

+ Pricing +

+

+ The right price for you, whoever you are +

+
+

+ Qui iusto aut est earum eos quae. Eligendi est at nam aliquid ad quo + reprehenderit in aliquid fugiat dolorum voluptatibus. +

+
+ {productTiers && + productTiers.map((tier, tierIdx) => ( +
+

+ {tier.name} +

+

+ + {tier.priceMonthly} + + /month +

+

+ {tier.description} +

+
    + {tier.features.map((feature) => ( +
  • +
    + {feature.includes("[x]") ? crossMarkIcon : checkMarkIcon} +
    + {feature.replace("[x]", "")} +
  • + ))} +
+ + Get started today + +
+ ))} +
+
+ ); +} diff --git a/web/src/app/(app)/pricing/const/Icon.tsx b/web/src/app/(app)/pricing/const/Icon.tsx new file mode 100644 index 0000000..2257aee --- /dev/null +++ b/web/src/app/(app)/pricing/const/Icon.tsx @@ -0,0 +1,37 @@ +export const checkMarkIcon = ( + +); + +export const crossMarkIcon = ( + + + + +); diff --git a/web/src/app/(app)/pricing/loading.tsx b/web/src/app/(app)/pricing/loading.tsx new file mode 100644 index 0000000..9ff4783 --- /dev/null +++ b/web/src/app/(app)/pricing/loading.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { LoadingPageWrapper } from "@/components/LoadingWrapper"; +import { usePathname } from "next/navigation"; + +export default function Loading() { + const pathName = usePathname(); + return ; +} diff --git a/web/src/app/(app)/pricing/page.tsx b/web/src/app/(app)/pricing/page.tsx new file mode 100644 index 0000000..fd94f22 --- /dev/null +++ b/web/src/app/(app)/pricing/page.tsx @@ -0,0 +1,13 @@ +"use client"; + +import { GpuPricingPlan } from "@/app/(app)/pricing/components/gpuPricingTable"; +import PricingList from "@/app/(app)/pricing/components/pricePlanList"; + +export default function Home() { + return ( +
+ + +
+ ); +} diff --git a/web/src/components/Navbar.tsx b/web/src/components/Navbar.tsx index 52be0c8..d344683 100644 --- a/web/src/components/Navbar.tsx +++ b/web/src/components/Navbar.tsx @@ -22,6 +22,7 @@ import { } from "@clerk/nextjs"; import { Github, Menu } from "lucide-react"; import meta from "next-gen/config"; +import { useFeatureFlagEnabled } from "posthog-js/react"; import { useEffect, useState } from "react"; import { useMediaQuery } from "usehooks-ts"; @@ -29,9 +30,13 @@ export function Navbar() { const { organization } = useOrganization(); const _isDesktop = useMediaQuery("(min-width: 1024px)"); const [isDesktop, setIsDesktop] = useState(true); + + const pricingPlanFlagEnable = useFeatureFlagEnabled("pricing-plan"); + useEffect(() => { setIsDesktop(_isDesktop); }, [_isDesktop]); + return ( <>
@@ -85,6 +90,15 @@ export function Navbar() {
{isDesktop && } + {pricingPlanFlagEnable && ( + + )} diff --git a/web/src/db/schema.ts b/web/src/db/schema.ts index 236983f..082017b 100644 --- a/web/src/db/schema.ts +++ b/web/src/db/schema.ts @@ -8,6 +8,7 @@ import { text, timestamp, uuid, + real, } from "drizzle-orm/pg-core"; import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { z } from "zod"; @@ -334,6 +335,19 @@ export const apiKeyTable = dbSchema.table("api_keys", { updated_at: timestamp("updated_at").defaultNow().notNull(), }); +export const userUsageTable = dbSchema.table("user_usage", { + id: uuid("id").primaryKey().defaultRandom().notNull(), + org_id: text("org_id"), + user_id: text("user_id") + .references(() => usersTable.id, { + onDelete: "cascade", + }) + .notNull(), + usage_time: real("usage_time").default(0).notNull(), + created_at: timestamp("created_at").defaultNow().notNull(), + ended_at: timestamp("ended_at").defaultNow().notNull(), +}); + export const authRequestsTable = dbSchema.table("auth_requests", { request_id: text("request_id").primaryKey().notNull(), user_id: text("user_id"), @@ -349,3 +363,4 @@ export type WorkflowType = InferSelectModel; export type MachineType = InferSelectModel; export type WorkflowVersionType = InferSelectModel; export type DeploymentType = InferSelectModel; +export type UserUsageType = InferSelectModel; diff --git a/web/src/server/linkToPricing.ts b/web/src/server/linkToPricing.ts new file mode 100644 index 0000000..b30efb7 --- /dev/null +++ b/web/src/server/linkToPricing.ts @@ -0,0 +1,45 @@ +"use server"; + +import { LemonSqueezy } from "@lemonsqueezy/lemonsqueezy.js"; +import "server-only"; + +const ls = new LemonSqueezy(process.env.LEMONSQUEEZY_API_KEY || ""); + +export async function getPricing() { + const products = await ls.getProducts(); + + return products; +} + +export async function getUsage() { + const usageRecord = await ls.getUsageRecords(); + + return usageRecord; +} + +export async function setUsage(id: number, quantity: number) { + const setUsage = await ls.createUsageRecord({ + subscriptionItemId: id, + quantity: quantity, + }); + + return setUsage; +} + +export async function getSubscription() { + const subscription = await ls.getSubscriptions(); + + return subscription; +} + +export async function getSubscriptionItem() { + const subscriptionItem = await ls.getSubscriptionItems(); + + return subscriptionItem; +} + +export async function getUserData() { + const user = await ls.getUser(); + + return user; +}