Merge remote-tracking branch 'origin/main' into cloudflare

This commit is contained in:
javayhu 2025-05-17 23:40:51 +08:00
commit 002d2090c2
15 changed files with 209 additions and 29 deletions

View File

@ -1,12 +1,12 @@
# MkSaaS
Make AI SaaS in days, simply and effortlessly.
Make AI SaaS in a weekend.
MkSaaS is a complete Next.js boilerplate for building AI SaaS websites. Make money from day one with our battle-tested stack and built-in monetization features.
The complete Next.js boilerplate for building profitable SaaS, with auth, payments, i18n, newsletter, dashboard, blog, docs, blocks, themes, SEO and more.
## Author
This project is created by [Fox](https://x.com/indie_maker_fox), the founder of [MkSaaS](https://mksaas.com) and [Mkdirs](https://mkdirs.com).
This project is created by [Fox](https://x.com/indie_maker_fox), the founder of [MkSaaS](https://mksaas.com) and [Mkdirs](https://mkdirs.com). The official X account for [MkSaaS](https://mksaas.com) is [@mksaascom](https://x.com/mksaascom), you can follow this account for the updates about MkSaaS.
## Documentation
@ -16,19 +16,19 @@ If you found anything that could be improved, please let me know.
## Links
- website: [mksaas.com](https://mksaas.com)
- demo: [demo.mksaas.com](https://demo.mksaas.com)
- discord: [discord.gg/mtjx6W6mNY](https://discord.gg/mtjx6W6mNY)
- documentation: [mksaas.com/docs](https://mksaas.com/docs)
- roadmap: [mksaas project](https://github.com/orgs/MkSaaSHQ/projects/1)
- video (WIP): [youtube.com/@MkSaaSHQ](https://www.youtube.com/@MkSaaSHQ)
- 🔥 website: [mksaas.com](https://mksaas.com)
- 🌐 demo: [demo.mksaas.com](https://demo.mksaas.com)
- 📚 documentation: [mksaas.com/docs](https://mksaas.com/docs)
- 🗓️ roadmap: [mksaas project](https://mksaas.link/roadmap)
- 👨‍💻 discord: [mksaas.link/discord](https://mksaas.link/discord)
- 📹 video (WIP): [mksaas.link/youtube](https://mksaas.link/youtube)
## Repositories
By default, you should have access to all four repositories. If you find that youre unable to access any of them, please dont hesitate to reach out to me, and Ill assist you in resolving the issue.
- [MkSaaSHQ/mksaas-template](https://github.com/MkSaaSHQ/mksaas-template): https://demo.mksaas.com (ready)
- [MkSaaSHQ/mksaas-blog](https://github.com/MkSaaSHQ/mksaas-blog): https://javayhu.com (ready)
- [MkSaaSHQ/mksaas-blog](https://github.com/MkSaaSHQ/mksaas-blog): https://mksaas.me (ready)
- [MkSaaSHQ/mksaas-app](https://github.com/MkSaaSHQ/mksaas-app): https://mksaas.app (WIP)
- [MkSaaSHQ/mksaas-haitang](https://github.com/MkSaaSHQ/mksaas-haitang): https://haitang.app (WIP)

View File

@ -5,8 +5,6 @@
# For development, set to http://localhost:3000 or any other port
# -----------------------------------------------------------------------------
NEXT_PUBLIC_BASE_URL="http://localhost:3000"
# If your development port is not 3000, please set PORT to your port, e.g. 3005
PORT=3000
# -----------------------------------------------------------------------------
# Database
@ -79,9 +77,9 @@ NEXT_PUBLIC_STRIPE_PRICE_LIFETIME=""
# Configurations
# -----------------------------------------------------------------------------
# Disable image optimization, check out next.config.ts for more details
DISABLE_IMAGE_OPTIMIZATION="false"
DISABLE_IMAGE_OPTIMIZATION=false
# Run this website as demo website, in most cases, you should set this to false
NEXT_PUBLIC_DEMO_WEBSITE="false"
NEXT_PUBLIC_DEMO_WEBSITE=false
# -----------------------------------------------------------------------------
# Analytics
@ -124,3 +122,10 @@ NEXT_PUBLIC_SELINE_TOKEN=""
# -----------------------------------------------------------------------------
NEXT_PUBLIC_DATAFAST_ANALYTICS_ID=""
NEXT_PUBLIC_DATAFAST_ANALYTICS_DOMAIN=""
# -----------------------------------------------------------------------------
# Discord
# -----------------------------------------------------------------------------
NEXT_PUBLIC_DISCORD_WIDGET_SERVER_ID=""
NEXT_PUBLIC_DISCORD_WIDGET_CHANNEL_ID=""

View File

@ -13,6 +13,7 @@
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"list-contacts": "tsx scripts/list-contacts.ts",
"docs": "content-collections build",
"email": "email dev --dir src/mail/templates --port 3333",
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
@ -75,6 +76,7 @@
"@types/canvas-confetti": "^1.9.0",
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
"@widgetbot/react-embed": "^1.9.0",
"ai": "^4.1.45",
"better-auth": "^1.1.19",
"canvas-confetti": "^1.9.3",

48
pnpm-lock.yaml generated
View File

@ -170,6 +170,9 @@ importers:
'@vercel/speed-insights':
specifier: ^1.2.0
version: 1.2.0(next@15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)
'@widgetbot/react-embed':
specifier: ^1.9.0
version: 1.9.0(react@19.0.0)
ai:
specifier: ^4.1.45
version: 4.1.45(react@19.0.0)(zod@3.24.2)
@ -4237,6 +4240,14 @@ packages:
vue-router:
optional: true
'@widgetbot/embed-api@1.2.17':
resolution: {integrity: sha512-qoiFLMak+mBG64pgKN5xFv3amPHcG2qcurPefAbof4DI/eip5OU59pbM+ak4d9d9OIkwA1QhoDzo9KWD/cOn0w==}
'@widgetbot/react-embed@1.9.0':
resolution: {integrity: sha512-+Qgqy7lwLy++lIiHmSsgxUjwcX80iFIHR0QJpKq4W82ePUmq4bTuxvUbxcE+VQH5IjNrWaydGNR8zROV5vUQsA==}
peerDependencies:
react: '>= 15'
accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
@ -4516,6 +4527,12 @@ packages:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
cross-domain-safe-weakmap@1.0.29:
resolution: {integrity: sha512-VLoUgf2SXnf3+na8NfeUFV59TRZkIJqCIATaMdbhccgtnTlSnHXkyTRwokngEGYdQXx8JbHT9GDYitgR2sdjuA==}
cross-domain-utils@2.0.38:
resolution: {integrity: sha512-zZfi3+2EIR9l4chrEiXI2xFleyacsJf8YMLR1eJ0Veb5FTMXeJ3DpxDjZkto2FhL/g717WSELqbptNSo85UJDw==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -6078,6 +6095,9 @@ packages:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
post-robot@8.0.32:
resolution: {integrity: sha512-PMOdDAt3pyuKUxZcTzdcXXFxLqkdeLpRlcCQl7QAJpI+e7J1YHH+PfC7KAbcL8hRVQ1LknQYGoirbA1/eO/a1g==}
postcss-selector-parser@7.1.0:
resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==}
engines: {node: '>=4'}
@ -6987,6 +7007,9 @@ packages:
youch@3.3.4:
resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==}
zalgo-promise@1.0.48:
resolution: {integrity: sha512-LLHANmdm53+MucY9aOFIggzYtUdkSBFxUsy4glTTQYNyK6B3uCPWTbfiGvSrEvLojw0mSzyFJ1/RRLv+QMNdzQ==}
zod-to-json-schema@3.24.2:
resolution: {integrity: sha512-pNUqrcSxuuB3/+jBbU8qKUbTbDqYUaG1vf5cXFjbhGgoUuA1amO/y4Q8lzfOhHU8HNPK6VFJ18lBDKj3OHyDsg==}
peerDependencies:
@ -11549,6 +11572,15 @@ snapshots:
next: 15.2.1(@babel/core@7.24.5)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
react: 19.0.0
'@widgetbot/embed-api@1.2.17':
dependencies:
post-robot: 8.0.32
'@widgetbot/react-embed@1.9.0(react@19.0.0)':
dependencies:
'@widgetbot/embed-api': 1.2.17
react: 19.0.0
accepts@1.3.8:
dependencies:
mime-types: 2.1.35
@ -11830,6 +11862,14 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
cross-domain-safe-weakmap@1.0.29:
dependencies:
cross-domain-utils: 2.0.38
cross-domain-utils@2.0.38:
dependencies:
zalgo-promise: 1.0.48
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@ -13820,6 +13860,12 @@ snapshots:
pluralize@8.0.0: {}
post-robot@8.0.32:
dependencies:
cross-domain-safe-weakmap: 1.0.29
cross-domain-utils: 2.0.38
zalgo-promise: 1.0.48
postcss-selector-parser@7.1.0:
dependencies:
cssesc: 3.0.0
@ -14909,6 +14955,8 @@ snapshots:
mustache: 4.2.0
stacktracey: 2.1.8
zalgo-promise@1.0.48: {}
zod-to-json-schema@3.24.2(zod@3.24.2):
dependencies:
zod: 3.24.2

25
scripts/list-contacts.ts Normal file
View File

@ -0,0 +1,25 @@
import dotenv from 'dotenv';
import { Resend } from 'resend';
dotenv.config();
const resend = new Resend(process.env.RESEND_API_KEY);
export default async function listContacts() {
const contacts = await resend.contacts.list({
audienceId: process.env.RESEND_AUDIENCE_ID!,
});
// print all emails
const emails: string[] = [];
if (Array.isArray(contacts.data?.data)) {
for (const contact of contacts.data.data) {
emails.push(contact.email);
}
} else {
console.error('contacts is not iterable');
}
console.log(emails.join(', '));
}
listContacts();

View File

@ -57,7 +57,7 @@ export const getUsersAction = actionClient
: user.createdAt;
const sortDirection = sortConfig?.desc ? desc : asc;
const [items, [{ count }]] = await Promise.all([
let [items, [{ count }]] = await Promise.all([
db
.select()
.from(user)
@ -68,6 +68,16 @@ export const getUsersAction = actionClient
db.select({ count: sql`count(*)` }).from(user).where(where),
]);
// hide user data in demo website
if (process.env.NEXT_PUBLIC_DEMO_WEBSITE === 'true') {
items = items.map((item) => ({
...item,
name: 'Demo User',
email: 'example@mksaas.com',
customerId: 'cus_abcdef123456',
}));
}
return {
success: true,
data: {

View File

@ -15,7 +15,7 @@ import { Providers } from './providers';
import '@/styles/globals.css';
import { Analytics } from '@/analytics/analytics';
import { TailwindIndicator } from '@/components/layout/tailwind-indicator';
import DiscordWidget from '@/components/shared/discord-widget';
interface LocaleLayoutProps {
children: ReactNode;
params: Promise<{ locale: Locale }>;
@ -56,6 +56,7 @@ export default async function LocaleLayout({
{children}
<Toaster richColors position="top-right" offset={64} />
<DiscordWidget />
<TailwindIndicator />
<Analytics />
</Providers>

View File

@ -134,7 +134,7 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
className="size-8 border"
/>
<span className="hover:underline hover:underline-offset-4">
{isDemo ? 'MkSaaS User' : user.name}
{user.name}
</span>
</div>
</Button>
@ -148,10 +148,8 @@ export function UserDetailViewer({ user }: UserDetailViewerProps) {
className="size-12 border"
/>
<div>
<DrawerTitle>{isDemo ? 'MkSaaS User' : user.name}</DrawerTitle>
<DrawerDescription>
{isDemo ? 'example@mksaas.com' : user.email}
</DrawerDescription>
<DrawerTitle>{user.name}</DrawerTitle>
<DrawerDescription>{user.email}</DrawerDescription>
</div>
</div>
</DrawerHeader>

View File

@ -168,7 +168,7 @@ export function UsersTable({
) : (
<MailQuestionIcon className="stroke-red-500 dark:stroke-red-400" />
)}
{isDemo ? 'example@mksaas.com' : user.email}
{user.email}
</Badge>
</div>
);
@ -227,7 +227,7 @@ export function UsersTable({
rel="noopener noreferrer"
className="hover:underline hover:underline-offset-4"
>
{!isDemo ? user.customerId : 'cus_abcdef123456'}
{user.customerId}
</a>
) : (
'-'

View File

@ -1,3 +1,6 @@
/**
* Tailwind Indicator, shows the current tailwind breakpoint
*/
export function TailwindIndicator() {
if (process.env.NODE_ENV === 'production') return null;

View File

@ -0,0 +1,80 @@
'use client';
import { DiscordIcon } from '@/components/icons/discord';
import { useMediaQuery } from '@/hooks/use-media-query';
import WidgetBot from '@widgetbot/react-embed';
import { useEffect, useRef, useState } from 'react';
/**
* Discord Widget, shows the channels and messages in the discord server
*
* https://docs.widgetbot.io/embed/react-embed/
*/
export default function DiscordWidget() {
const serverId = process.env.NEXT_PUBLIC_DISCORD_WIDGET_SERVER_ID as string;
const channelId = process.env.NEXT_PUBLIC_DISCORD_WIDGET_CHANNEL_ID as string;
if (!serverId || !channelId) {
return null;
}
const [open, setOpen] = useState(false);
const widgetRef = useRef<HTMLDivElement>(null);
const { device, width: windowWidth, height: windowHeight } = useMediaQuery();
let widgetWidth = 800;
let widgetHeight = 600;
if (device === 'mobile') {
widgetWidth = windowWidth ? Math.floor(windowWidth * 0.9) : 320;
widgetHeight = windowHeight ? Math.floor(windowHeight * 0.8) : 400;
} else if (device === 'tablet' || device === 'sm') {
widgetWidth = windowWidth ? Math.floor(windowWidth * 0.9) : 600;
widgetHeight = windowHeight ? Math.floor(windowHeight * 0.8) : 480;
}
useEffect(() => {
if (!open) return;
function handleClick(e: MouseEvent) {
if (widgetRef.current && !widgetRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [open]);
return (
<div>
{/* discord icon button, show in bottom right corner */}
{!open && (
<button
aria-label="Open Discord Widget"
className="fixed bottom-[84px] right-10 z-50 cursor-pointer flex items-center justify-center rounded-full bg-[#5865F2] shadow-lg
hover:scale-110 transition-transform duration-150"
style={{ width: 48, height: 48 }}
onClick={() => setOpen(true)}
type="button"
>
<DiscordIcon width={32} height={32} className="text-white" />
</button>
)}
{/* discord widget expand layer */}
{open && (
<div
ref={widgetRef}
className="fixed bottom-[84px] right-10 z-50 flex flex-col items-end"
style={{ width: widgetWidth, height: widgetHeight }}
>
<div className="rounded-lg overflow-hidden shadow-2xl border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900">
<WidgetBot
server={serverId}
channel={channelId}
width={widgetWidth}
height={widgetHeight}
/>
</div>
</div>
)}
</div>
);
}

View File

@ -24,10 +24,12 @@ export const websiteConfig: WebsiteConfig = {
},
social: {
github: 'https://github.com/MkSaaSHQ',
twitter: 'https://x.com/mksaascom',
blueSky: 'https://bsky.app/profile/mksaas.com',
discord: 'https://discord.gg/yVwpEtTT',
youtube: 'https://www.youtube.com/@MkSaaS',
twitter: 'https://mksaas.link/twitter',
blueSky: 'https://mksaas.link/bsky',
discord: 'https://mksaas.link/discord',
mastodon: 'https://mksaas.link/mastodon',
linkedin: 'https://mksaas.link/linkedin',
youtube: 'https://mksaas.link/youtube',
},
},
routes: {

View File

@ -10,7 +10,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { admin } from 'better-auth/plugins';
import { parse as parseCookies } from 'cookie';
import type { Locale } from 'next-intl';
import { getUrlWithLocaleInCallbackUrl } from './urls/urls';
import { getBaseUrl, getUrlWithLocaleInCallbackUrl } from './urls/urls';
/**
* Better Auth configuration
@ -20,6 +20,7 @@ import { getUrlWithLocaleInCallbackUrl } from './urls/urls';
* https://www.better-auth.com/docs/reference/options
*/
export const auth = betterAuth({
baseURL: getBaseUrl(),
appName: defaultMessages.Metadata.name,
database: drizzleAdapter(db, {
provider: 'pg', // or "mysql", "sqlite"

View File

@ -1,6 +1,7 @@
import { randomUUID } from 'crypto';
import db from '@/db';
import { payment, session, user } from '@/db/schema';
import { sendMessageToDiscord } from '@/lib/discord';
import {
findPlanByPlanId,
findPlanByPriceId,
@ -617,6 +618,10 @@ export class StripeProvider implements PaymentProvider {
console.log(
`<< Created one-time payment record for user ${userId}, price: ${priceId}`
);
// Send message to Discord channel
const amount = session.amount_total ? session.amount_total / 100 : 0;
await sendMessageToDiscord(session.id, customerId, userId, amount);
}
/**

View File

@ -16,7 +16,7 @@ export enum Routes {
Contact = '/contact',
Waitlist = '/waitlist',
Changelog = '/changelog',
Roadmap = 'https://github.com/orgs/MkSaaSHQ/projects/1',
Roadmap = 'https://mksaas.link/roadmap',
CookiePolicy = '/cookie',
PrivacyPolicy = '/privacy',
TermsOfService = '/terms',