Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
57bdbb3
feat: init web-shadcn with tailwind + shadcn/ui
xenia-levchenko Feb 10, 2026
e967682
feat: add base components
xenia-levchenko Feb 10, 2026
36435f5
feat: add base components
xenia-levchenko Feb 10, 2026
421bed3
feat: add base components
xenia-levchenko Feb 10, 2026
438272f
feat: add base components
xenia-levchenko Feb 10, 2026
78a7093
refactor
xenia-levchenko Feb 10, 2026
b4fddb6
feat: add layout
xenia-levchenko Feb 10, 2026
6657670
add sign-up page
xenia-levchenko Feb 11, 2026
659be70
add sign-in and forgot-pass pages
xenia-levchenko Feb 11, 2026
858e1b5
add layout and rest pages
xenia-levchenko Feb 12, 2026
c242e2f
refactor
xenia-levchenko Feb 12, 2026
cd71095
add front for chat and sidebar
xenia-levchenko Feb 17, 2026
deffdbe
add LLM chat with streaming
xenia-levchenko Feb 18, 2026
307b054
refactor
xenia-levchenko Feb 18, 2026
c75a6ec
refactor
xenia-levchenko Feb 18, 2026
5afe2f3
refactor
xenia-levchenko Feb 18, 2026
32a2937
add useApiMutation and useApiQuery
xenia-levchenko Feb 19, 2026
4d1fffd
add navbar to layout
xenia-levchenko Feb 19, 2026
f69d486
add admin page
xenia-levchenko Feb 19, 2026
79e0a49
refactor
xenia-levchenko Feb 19, 2026
7453b5f
add useApiStreamMutation hook
xenia-levchenko Feb 19, 2026
a0c6222
fix mobile design
xenia-levchenko Feb 23, 2026
d03a6c3
refactor
xenia-levchenko Feb 23, 2026
035c4ea
add schemas
xenia-levchenko Feb 24, 2026
951621a
create stripe service
xenia-levchenko Feb 24, 2026
854e22d
add endpoints
xenia-levchenko Feb 25, 2026
41c1709
add webhook
xenia-levchenko Feb 25, 2026
552b22d
add pricing page + fixes
xenia-levchenko Feb 26, 2026
1f205d7
refactor
xenia-levchenko Feb 26, 2026
cf7e8b6
remove web-shadcn (not needed in this branch)
xenia-levchenko Feb 26, 2026
65af0eb
pr fixes
xenia-levchenko Mar 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions template/apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,8 @@ WEB_URL=http://localhost:3002

# Google AI (Gemini)
# GOOGLE_GENERATIVE_AI_API_KEY=...

# Stripe
# Get your API keys from https://dashboard.stripe.com/test/apikeys
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_WEBHOOK_SECRET=whsec_...
4 changes: 4 additions & 0 deletions template/apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"ioredis": "5.6.1",
"koa": "3.0.3",
"koa-body": "6.0.1",
"koa-bodyparser": "4.4.1",
"koa-compose": "4.1.0",
"koa-helmet": "8.0.1",
"koa-logger": "4.0.0",
Expand All @@ -49,13 +50,16 @@
"mixpanel": "0.18.1",
"node-schedule": "2.1.1",
"resend": "4.5.2",
"shared": "workspace:*",
"socket.io": "4.8.1",
"stripe": "20.3.1",
"tldts": "7.0.8",
"winston": "3.17.0",
"zod": "catalog:"
},
"devDependencies": {
"@types/koa": "2.15.0",
"@types/koa-bodyparser": "4.3.13",
"@types/koa-compose": "3.2.8",
"@types/koa-logger": "3.1.5",
"@types/koa-mount": "4.0.5",
Expand Down
18 changes: 13 additions & 5 deletions template/apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import cors from '@koa/cors';
import { koaBody } from 'koa-body';
import bodyParser from 'koa-bodyparser';
import helmet from 'koa-helmet';
import koaLogger from 'koa-logger';
import qs from 'koa-qs';
Expand All @@ -25,16 +26,23 @@ const initKoa = async () => {
app.use(helmet());
qs(app);
app.use(
koaBody({
multipart: true,
onError: (error, ctx) => {
const errText: string = error.stack || error.toString();

bodyParser({
enableTypes: ['json', 'form', 'text'],
onerror: (err, ctx) => {
const errText: string = err.stack || err.toString();
logger.warn(`Unable to parse request body. ${errText}`);
ctx.throw(422, 'Unable to parse request JSON.');
},
}),
);
app.use(
koaBody({
multipart: true,
json: false,
urlencoded: false,
text: false,
}),
);
app.use(
koaLogger({
transporter: (message, args) => {
Expand Down
2 changes: 2 additions & 0 deletions template/apps/api/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const schema = z.object({
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
GOOGLE_GENERATIVE_AI_API_KEY: z.string().optional(),
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
});

type Config = z.infer<typeof schema>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { userService } from 'resources/users';

import isPublic from 'middlewares/isPublic';
import rateLimitMiddleware from 'middlewares/rateLimit';
import { authService, emailService } from 'services';
import { authService, emailService, stripeService } from 'services';
import createEndpoint from 'routes/createEndpoint';

import config from 'config';
Expand Down Expand Up @@ -45,6 +45,10 @@ export default createEndpoint({

await userService.updateOne({ _id: user._id }, () => ({ isEmailVerified: true }));

if (!user.stripeId) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider when create?

await stripeService.createCustomer(user);
}

await authService.setAccessToken({ ctx, userId: user._id });

await emailService.sendTemplate<Template.SIGN_UP_WELCOME>({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { stripeService } from 'services';
import createEndpoint from 'routes/createEndpoint';

import config from 'config';

export default createEndpoint({
method: 'post',
path: '/create-portal-session',

async handler(ctx) {
const { user } = ctx.state;

const stripeId = user.stripeId || (await stripeService.createCustomer(user));

if (!stripeId) {
return ctx.throwError('Failed to create Stripe customer');
}

const session = await stripeService.billingPortal.sessions.create({
customer: stripeId,
return_url: `${config.WEB_URL}/pricing?portal=true`,
});

return { portalUrl: session.url };
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Stripe from 'stripe';

import { userService } from 'resources/users';

import { stripeService } from 'services';
import createEndpoint from 'routes/createEndpoint';

export default createEndpoint({
method: 'get',
path: '/current',

async handler(ctx) {
const { user } = ctx.state;

if (!user.subscription) {
return null;
}

const stripeSubscription = (await stripeService.subscriptions.retrieve(
user.subscription.subscriptionId,
)) as Stripe.Subscription;

if (stripeSubscription.status === 'canceled') {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enum

await userService.atomic.updateOne({ _id: user._id }, { $unset: { subscription: '' } });
return null;
}

const isCanceled = stripeSubscription.cancel_at_period_end || stripeSubscription.cancel_at !== null;

const stripePeriodEnd =
stripeSubscription.cancel_at ??
(stripeSubscription as unknown as { current_period_end: number }).current_period_end;

const needsUpdate =
user.subscription.cancelAtPeriodEnd !== isCanceled ||
user.subscription.status !== stripeSubscription.status ||
user.subscription.priceId !== (stripeSubscription.items.data[0]?.price.id ?? user.subscription.priceId) ||
user.subscription.currentPeriodEndDate !== stripePeriodEnd;

if (needsUpdate) {
const updatedSubscription = {
...user.subscription,
priceId: stripeSubscription.items.data[0]?.price.id ?? user.subscription.priceId,
cancelAtPeriodEnd: isCanceled,
status: stripeSubscription.status,
currentPeriodStartDate: (stripeSubscription as unknown as { current_period_start: number })
.current_period_start,
currentPeriodEndDate: stripePeriodEnd,
};

await userService.atomic.updateOne({ _id: user._id }, { $set: { subscription: updatedSubscription } });
user.subscription = updatedSubscription;
}

const product = await stripeService.products.retrieve(user.subscription.productId);

return {
...user.subscription,
product: {
name: product.name,
images: product.images,
},
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { z } from 'zod';

import { stripeService } from 'services';
import createEndpoint from 'routes/createEndpoint';

const schema = z.object({
priceId: z.string().min(1, 'Price ID is required'),
});

export default createEndpoint({
method: 'get',
path: '/preview-upgrade',
schema,

async handler(ctx) {
const { user } = ctx.state;
const { priceId } = ctx.validatedData;

if (!user.stripeId) {
return ctx.throwError('Stripe account not found.');
}

if (!user.subscription?.subscriptionId) {
return ctx.throwError('No active subscription found.');
}

const subscriptionId = user.subscription.subscriptionId;
const subscriptionDetails = await stripeService.subscriptions.retrieve(subscriptionId);

const invoice = await stripeService.invoices.createPreview({
customer: user.stripeId,
subscription: subscriptionId,
subscription_details: {
proration_behavior: 'always_invoice',
items: [
{
id: subscriptionDetails.items.data[0].id,
price: priceId,
},
],
},
});

return {
subtotal: invoice.subtotal,
total: invoice.total,
amountDue: invoice.amount_due,
currency: invoice.currency,
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { z } from 'zod';

import { stripeService } from 'services';
import createEndpoint from 'routes/createEndpoint';

import config from 'config';

const schema = z.object({
priceId: z.string().min(1, 'Price ID is required'),
});

export default createEndpoint({
method: 'post',
path: '/subscribe',
schema,

async handler(ctx) {
const { user } = ctx.state;
const { priceId } = ctx.validatedData;

const stripeId = user.stripeId || (await stripeService.createCustomer(user));

if (!stripeId) {
return ctx.throwError('Failed to create Stripe customer');
}

if (user.subscription) {
return ctx.throwError('You already have an active subscription. Please upgrade instead.');
}

const session = await stripeService.checkout.sessions.create({
mode: 'subscription',
customer: stripeId,
line_items: [
{
quantity: 1,
price: priceId,
},
],
success_url: `${config.WEB_URL}/pricing?success=true`,
cancel_url: `${config.WEB_URL}/pricing?canceled=true`,
});

if (!session.url) {
return ctx.throwError('Unable to create checkout session');
}

return { checkoutUrl: session.url };
},
});
66 changes: 66 additions & 0 deletions template/apps/api/src/resources/subscriptions/endpoints/upgrade.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Stripe from 'stripe';
import { z } from 'zod';

import { userService } from 'resources/users';

import { stripeService } from 'services';
import createEndpoint from 'routes/createEndpoint';

const schema = z.object({
priceId: z.string().min(1, 'Price ID is required'),
});

export default createEndpoint({
method: 'post',
path: '/upgrade',
schema,

async handler(ctx) {
const { user } = ctx.state;
const { priceId } = ctx.validatedData;

if (!user.stripeId) {
return ctx.throwError('Stripe account not found. Please contact support.');
}

if (!user.subscription?.subscriptionId) {
return ctx.throwError('No active subscription found.');
}

const subscriptionId = user.subscription.subscriptionId;

if (priceId === 'free') {
await stripeService.subscriptions.cancel(subscriptionId);
await userService.atomic.updateOne({ _id: user._id }, { $unset: { subscription: '' } });
return { success: true, message: 'Subscription canceled' };
}

const subscriptionDetails = (await stripeService.subscriptions.retrieve(subscriptionId)) as Stripe.Subscription;

const updatedStripeSubscription = (await stripeService.subscriptions.update(subscriptionId, {
proration_behavior: 'always_invoice',
cancel_at_period_end: false,
items: [
{
id: subscriptionDetails.items.data[0].id,
price: priceId,
},
],
})) as Stripe.Subscription;

const updatedSubscription = {
...user.subscription,
priceId: updatedStripeSubscription.items.data[0]?.price.id ?? priceId,
productId: updatedStripeSubscription.items.data[0]?.price.product as string,
status: updatedStripeSubscription.status,
cancelAtPeriodEnd: updatedStripeSubscription.cancel_at_period_end,
currentPeriodStartDate: (updatedStripeSubscription as unknown as { current_period_start: number })
.current_period_start,
currentPeriodEndDate: (updatedStripeSubscription as unknown as { current_period_end: number }).current_period_end,
};

await userService.atomic.updateOne({ _id: user._id }, { $set: { subscription: updatedSubscription } });

return { success: true };
},
});
4 changes: 4 additions & 0 deletions template/apps/api/src/resources/users/user.schema.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { subscriptionSchema } from 'shared';
import { z } from 'zod';

import { dbSchema, emailSchema } from '../base.schema';
Expand Down Expand Up @@ -25,6 +26,9 @@ export const userSchema = dbSchema.extend({
.optional(),

lastRequest: z.date().optional(),

stripeId: z.string().optional().nullable(),
subscription: subscriptionSchema.optional().nullable(),
});

export type User = z.infer<typeof userSchema>;
Expand Down
Loading