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 (
+
+
+
+
+
+
+ GPU
+ |
+
+ No.
+ |
+
+ RAM
+ |
+
+ Price
+ |
+
+
+
+ {people.map((person) => (
+
+
+ {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}
+
+
+
+ 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;
+}