feat: show active subscription on marketplace project page

pull/715/head
Travis Fischer 2025-06-18 14:23:26 +07:00
rodzic df601af335
commit a085cbb2a4
8 zmienionych plików z 182 dodań i 18 usunięć

Wyświetl plik

@ -0,0 +1,72 @@
import { assert, parseZodSchema } from '@agentic/platform-core'
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
import type { AuthenticatedHonoEnv } from '@/lib/types'
import { and, db, eq, schema } from '@/db'
import { acl } from '@/lib/acl'
import { aclPublicProject } from '@/lib/acl-public-project'
import {
openapiAuthenticatedSecuritySchemas,
openapiErrorResponse404,
openapiErrorResponses
} from '@/lib/openapi-utils'
import { projectIdentifierAndPopulateConsumerSchema } from './schemas'
const route = createRoute({
description:
'Gets a consumer for the authenticated user and the given project identifier.',
tags: ['consumers'],
operationId: 'getConsumerByProjectIdentifier',
method: 'get',
path: 'consumers/by-project-identifier',
security: openapiAuthenticatedSecuritySchemas,
request: {
query: projectIdentifierAndPopulateConsumerSchema
},
responses: {
200: {
description: 'A consumer object',
content: {
'application/json': {
schema: schema.consumerSelectSchema
}
}
},
...openapiErrorResponses,
...openapiErrorResponse404
}
})
export function registerV1GetConsumerByProjectIdentifier(
app: OpenAPIHono<AuthenticatedHonoEnv>
) {
return app.openapi(route, async (c) => {
const { projectIdentifier, populate = [] } = c.req.valid('query')
const userId = c.get('userId')
const project = await db.query.projects.findFirst({
where: eq(schema.projects.identifier, projectIdentifier)
})
assert(project, 404, `Project not found "${projectIdentifier}"`)
await aclPublicProject(project)
const consumer = await db.query.consumers.findFirst({
where: and(
eq(schema.consumers.userId, userId),
eq(schema.consumers.projectId, project.id)
),
with: {
...Object.fromEntries(populate.map((field) => [field, true]))
}
})
assert(
consumer,
404,
`Consumer not found for user "${userId}" and project "${projectIdentifier}"`
)
await acl(c, consumer, { label: 'Consumer' })
return c.json(parseZodSchema(schema.consumerSelectSchema, consumer))
})
}

Wyświetl plik

@ -3,7 +3,8 @@ import { z } from '@hono/zod-openapi'
import {
consumerIdSchema,
consumerRelationsSchema,
paginationSchema
paginationSchema,
projectIdentifierSchema
} from '@/db'
export const consumerIdParamsSchema = z.object({
@ -29,6 +30,10 @@ export const consumerTokenParamsSchema = z.object({
})
})
export const projectIdentifierQuerySchema = z.object({
projectIdentifier: projectIdentifierSchema
})
export const populateConsumerSchema = z.object({
populate: z
.union([consumerRelationsSchema, z.array(consumerRelationsSchema)])
@ -41,3 +46,8 @@ export const paginationAndPopulateConsumerSchema = z.object({
...paginationSchema.shape,
...populateConsumerSchema.shape
})
export const projectIdentifierAndPopulateConsumerSchema = z.object({
...projectIdentifierQuerySchema.shape,
...populateConsumerSchema.shape
})

Wyświetl plik

@ -16,6 +16,7 @@ import { registerV1AdminGetConsumerByToken } from './consumers/admin-get-consume
import { registerV1CreateConsumer } from './consumers/create-consumer'
import { registerV1CreateConsumerCheckoutSession } from './consumers/create-consumer-checkout-session'
import { registerV1GetConsumer } from './consumers/get-consumer'
import { registerV1GetConsumerByProjectIdentifier } from './consumers/get-consumer-by-project-identifier'
import { registerV1ListConsumers } from './consumers/list-consumers'
import { registerV1ListConsumersForProject } from './consumers/list-project-consumers'
import { registerV1RefreshConsumerToken } from './consumers/refresh-consumer-token'
@ -118,6 +119,7 @@ registerV1GetProject(privateRouter)
registerV1UpdateProject(privateRouter)
// Consumers
registerV1GetConsumerByProjectIdentifier(privateRouter) // msut be before `registerV1GetConsumer`
registerV1GetConsumer(privateRouter)
registerV1CreateConsumer(privateRouter)
registerV1CreateConsumerCheckoutSession(privateRouter)

Wyświetl plik

@ -20,6 +20,7 @@ export function MarketplaceProjectIndex({
const checkout = searchParams.get('checkout')
const plan = searchParams.get('plan')
// Load the public project.
const {
data: project,
isLoading,
@ -34,6 +35,26 @@ export function MarketplaceProjectIndex({
enabled: !!ctx
})
// If the user is authenticated, check if they have an active subscription to
// this project.
const {
data: consumer
// isLoading: isConsumerLoading,
// isError: isConsumerError
} = useQuery({
queryKey: [
'project',
projectIdentifier,
'user',
ctx?.api.authSession?.user.id
],
queryFn: () =>
ctx!.api.getConsumerByProjectIdentifier({
projectIdentifier
}),
enabled: !!ctx?.isAuthenticated
})
const onSubscribe = useCallback(
async (pricingPlanSlug: string) => {
assert(ctx, 500, 'Agentic context is required')
@ -61,7 +82,7 @@ export function MarketplaceProjectIndex({
plan: pricingPlanSlug
})
console.log('checkoutSession', res)
console.log('checkout', res)
checkoutSession = res.checkoutSession
} catch (err) {
return toastError(err, { label: 'Error creating checkout session' })
@ -146,17 +167,15 @@ export function MarketplaceProjectIndex({
<pre className='max-w-lg'>{JSON.stringify(plan, null, 2)}</pre>
<Button
onClick={() =>
onSubscribe(
project.lastPublishedDeployment?.pricingPlans[0]?.slug ??
'free'
)
}
disabled={
!project.lastPublishedDeployment?.pricingPlans[0]?.slug
}
onClick={() => onSubscribe(plan.slug)}
// TODO: handle free plans correctly
disabled={consumer?.plan === plan.slug}
>
Subscribe to "{plan.name}"
{consumer?.plan === plan.slug ? (
<span>Currently subscribed to "{plan.name}"</span>
) : (
<span>Subscribe to "{plan.name}"</span>
)}
</Button>
</div>
))}

Wyświetl plik

@ -25,9 +25,6 @@ export function useConfettiFireworks() {
...options
}
const randomInRange = (min: number, max: number) =>
Math.random() * (max - min) + min
const interval = globalThis.window.setInterval(() => {
const timeLeft = animationEnd - Date.now()
@ -58,3 +55,7 @@ export function useConfettiFireworks() {
fireConfetti
}
}
function randomInRange(min: number, max: number) {
return Math.random() * (max - min) + min
}

Wyświetl plik

@ -491,6 +491,23 @@ export class AgenticApiClient {
.json()
}
/** Gets a consumer by ID. */
async getConsumerByProjectIdentifier<
TPopulate extends NonNullable<
OperationParameters<'getConsumerByProjectIdentifier'>['populate']
>[number]
>(
searchParams: OperationParameters<'getConsumerByProjectIdentifier'> & {
populate?: TPopulate[]
}
): Promise<PopulateConsumer<TPopulate>> {
return this.ky
.get(`v1/consumers/by-project-identifier`, {
searchParams: sanitizeSearchParams(searchParams)
})
.json()
}
/**
* Updates a consumer's subscription to a different deployment or pricing
* plan. Set `plan` to undefined to cancel the subscription.

Wyświetl plik

@ -283,6 +283,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/consumers/by-project-identifier": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @description Gets a consumer for the authenticated user and the given project identifier. */
get: operations["getConsumerByProjectIdentifier"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/consumers/{consumerId}": {
parameters: {
query?: never;
@ -1717,6 +1734,34 @@ export interface operations {
404: components["responses"]["404"];
};
};
getConsumerByProjectIdentifier: {
parameters: {
query: {
/** @description Public project identifier (e.g. "@namespace/project-name") */
projectIdentifier: components["schemas"]["ProjectIdentifier"];
populate?: ("user" | "project" | "deployment") | ("user" | "project" | "deployment")[];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description A consumer object */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Consumer"];
};
};
400: components["responses"]["400"];
401: components["responses"]["401"];
403: components["responses"]["403"];
404: components["responses"]["404"];
};
};
getConsumer: {
parameters: {
query?: {

Wyświetl plik

@ -15,11 +15,9 @@
- **website**
- marketing landing page
- webapp
- consider a PrettyJson component which displays json but links to resources
- stripe
- if user is subscribed to a plan, show that plan as selected
- handle unauthenticated checkout flow => auth and then redirect to create a checkout session
- will need a `redirect` url for `/login` and `/signup`
- `/marketplace/projects/@{projectIdentifier}?checkout=true&plan={plan}`
- stripe checkout
- stripe billing portal
- **API gateway**