create-skit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/bin/create-skit.mjs +1064 -0
- package/lib/module-application.mjs +281 -0
- package/lib/module-resolver.mjs +179 -0
- package/modules/README.md +22 -0
- package/modules/ai-dx/files/AGENTS.md +116 -0
- package/modules/ai-dx/files/ARCHITECTURE.md +103 -0
- package/modules/ai-dx/module.json +14 -0
- package/modules/ai-dx-claude/files/CLAUDE.md +8 -0
- package/modules/ai-dx-claude/module.json +13 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/auth.mdc +53 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/database.mdc +48 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/env.mdc +43 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/nextjs.mdc +58 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/project.mdc +33 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/testing.mdc +55 -0
- package/modules/ai-dx-cursor/module.json +18 -0
- package/modules/ai-dx-gemini/files/.gemini/GEMINI.md +5 -0
- package/modules/ai-dx-gemini/module.json +13 -0
- package/modules/auth-core/module.json +8 -0
- package/modules/auth-github/module.json +20 -0
- package/modules/billing-polar/module.json +20 -0
- package/modules/billing-stripe/module.json +23 -0
- package/modules/dashboard-shell/files/src/app/globals.css +756 -0
- package/modules/dashboard-shell/files/src/app/settings/page.tsx +67 -0
- package/modules/dashboard-shell/module.json +11 -0
- package/modules/db-pg/module.json +21 -0
- package/modules/db-postgresjs/module.json +21 -0
- package/modules/deploy-docker/files/.dockerignore +19 -0
- package/modules/deploy-docker/files/Dockerfile +25 -0
- package/modules/deploy-docker/module.json +11 -0
- package/modules/email-resend/module.json +21 -0
- package/modules/quality-baseline/module.json +8 -0
- package/modules/testing-baseline/module.json +8 -0
- package/package.json +40 -0
- package/presets/README.md +12 -0
- package/presets/blank.json +67 -0
- package/presets/dashboard.json +67 -0
- package/templates/base-web/.env.example +17 -0
- package/templates/base-web/.github/workflows/ci.yml +34 -0
- package/templates/base-web/.husky/pre-commit +3 -0
- package/templates/base-web/.husky/pre-push +3 -0
- package/templates/base-web/.prettierignore +3 -0
- package/templates/base-web/README.md +42 -0
- package/templates/base-web/drizzle.config.ts +16 -0
- package/templates/base-web/eslint.config.mjs +127 -0
- package/templates/base-web/manifest.json +5 -0
- package/templates/base-web/next-env.d.ts +4 -0
- package/templates/base-web/next.config.ts +5 -0
- package/templates/base-web/package.json +75 -0
- package/templates/base-web/playwright.config.ts +21 -0
- package/templates/base-web/prettier.config.mjs +9 -0
- package/templates/base-web/proxy.ts +23 -0
- package/templates/base-web/src/app/api/auth/[...all]/route.ts +5 -0
- package/templates/base-web/src/app/api/billing/checkout/route.ts +26 -0
- package/templates/base-web/src/app/api/billing/portal/route.ts +25 -0
- package/templates/base-web/src/app/api/email/test/route.ts +28 -0
- package/templates/base-web/src/app/api/webhooks/polar/route.ts +5 -0
- package/templates/base-web/src/app/api/webhooks/stripe/route.ts +5 -0
- package/templates/base-web/src/app/billing/page.tsx +55 -0
- package/templates/base-web/src/app/dashboard/page.tsx +15 -0
- package/templates/base-web/src/app/email/page.tsx +46 -0
- package/templates/base-web/src/app/error.tsx +27 -0
- package/templates/base-web/src/app/globals.css +534 -0
- package/templates/base-web/src/app/layout.tsx +19 -0
- package/templates/base-web/src/app/llms-full.txt/route.ts +158 -0
- package/templates/base-web/src/app/llms.txt/route.ts +59 -0
- package/templates/base-web/src/app/loading.tsx +24 -0
- package/templates/base-web/src/app/not-found.tsx +16 -0
- package/templates/base-web/src/app/page.tsx +5 -0
- package/templates/base-web/src/app/sign-in/page.tsx +14 -0
- package/templates/base-web/src/app/sign-up/page.tsx +14 -0
- package/templates/base-web/src/components/auth/email-auth-form.test.tsx +40 -0
- package/templates/base-web/src/components/auth/email-auth-form.tsx +128 -0
- package/templates/base-web/src/components/auth/sign-out-button.tsx +29 -0
- package/templates/base-web/src/db/index.ts +16 -0
- package/templates/base-web/src/db/schema/auth.ts +4 -0
- package/templates/base-web/src/db/schema/index.ts +2 -0
- package/templates/base-web/src/db/schema/projects.ts +17 -0
- package/templates/base-web/src/db/seeds/index.ts +32 -0
- package/templates/base-web/src/lib/auth-client.ts +5 -0
- package/templates/base-web/src/lib/auth-session.ts +21 -0
- package/templates/base-web/src/lib/auth.ts +23 -0
- package/templates/base-web/src/lib/billing/index.ts +37 -0
- package/templates/base-web/src/lib/billing/providers/polar.ts +80 -0
- package/templates/base-web/src/lib/billing/providers/stripe.ts +77 -0
- package/templates/base-web/src/lib/billing/types.ts +25 -0
- package/templates/base-web/src/lib/email/index.ts +19 -0
- package/templates/base-web/src/lib/email/templates.test.ts +12 -0
- package/templates/base-web/src/lib/email/templates.ts +40 -0
- package/templates/base-web/src/lib/env.ts +83 -0
- package/templates/base-web/tests/e2e/home.spec.ts +8 -0
- package/templates/base-web/tsconfig.json +34 -0
- package/templates/base-web/vitest.config.ts +19 -0
- package/templates/blank/.env.example +16 -0
- package/templates/blank/.github/workflows/ci.yml +34 -0
- package/templates/blank/.husky/pre-commit +3 -0
- package/templates/blank/.husky/pre-push +3 -0
- package/templates/blank/.prettierignore +3 -0
- package/templates/blank/drizzle.config.ts +16 -0
- package/templates/blank/eslint.config.mjs +127 -0
- package/templates/blank/next-env.d.ts +4 -0
- package/templates/blank/next.config.ts +5 -0
- package/templates/blank/package.json +75 -0
- package/templates/blank/playwright.config.ts +21 -0
- package/templates/blank/prettier.config.mjs +9 -0
- package/templates/blank/proxy.ts +28 -0
- package/templates/blank/src/app/api/auth/[...all]/route.ts +5 -0
- package/templates/blank/src/app/api/billing/checkout/route.ts +26 -0
- package/templates/blank/src/app/api/billing/portal/route.ts +25 -0
- package/templates/blank/src/app/api/email/test/route.ts +28 -0
- package/templates/blank/src/app/api/webhooks/polar/route.ts +5 -0
- package/templates/blank/src/app/api/webhooks/stripe/route.ts +5 -0
- package/templates/blank/src/app/billing/page.tsx +70 -0
- package/templates/blank/src/app/email/page.tsx +46 -0
- package/templates/blank/src/app/globals.css +394 -0
- package/templates/blank/src/app/layout.tsx +19 -0
- package/templates/blank/src/app/page.tsx +23 -0
- package/templates/blank/src/app/sign-in/page.tsx +18 -0
- package/templates/blank/src/app/sign-up/page.tsx +18 -0
- package/templates/blank/src/components/auth/email-auth-form.test.tsx +39 -0
- package/templates/blank/src/components/auth/email-auth-form.tsx +109 -0
- package/templates/blank/src/components/auth/sign-out-button.tsx +29 -0
- package/templates/blank/src/db/index.ts +16 -0
- package/templates/blank/src/db/schema/auth.ts +4 -0
- package/templates/blank/src/db/schema/index.ts +2 -0
- package/templates/blank/src/db/schema/projects.ts +17 -0
- package/templates/blank/src/db/seeds/index.ts +28 -0
- package/templates/blank/src/lib/auth-client.ts +5 -0
- package/templates/blank/src/lib/auth-session.ts +11 -0
- package/templates/blank/src/lib/auth.ts +23 -0
- package/templates/blank/src/lib/billing/index.ts +37 -0
- package/templates/blank/src/lib/billing/providers/polar.ts +80 -0
- package/templates/blank/src/lib/billing/providers/stripe.ts +77 -0
- package/templates/blank/src/lib/billing/types.ts +25 -0
- package/templates/blank/src/lib/email/index.ts +19 -0
- package/templates/blank/src/lib/email/templates.test.ts +15 -0
- package/templates/blank/src/lib/email/templates.ts +40 -0
- package/templates/blank/src/lib/env.ts +80 -0
- package/templates/blank/tsconfig.json +34 -0
- package/templates/blank/vitest.config.ts +19 -0
- package/templates/dashboard/.env.example +16 -0
- package/templates/dashboard/.github/workflows/ci.yml +34 -0
- package/templates/dashboard/.husky/pre-commit +3 -0
- package/templates/dashboard/.husky/pre-push +3 -0
- package/templates/dashboard/.prettierignore +3 -0
- package/templates/dashboard/drizzle.config.ts +16 -0
- package/templates/dashboard/eslint.config.mjs +127 -0
- package/templates/dashboard/next-env.d.ts +4 -0
- package/templates/dashboard/next.config.ts +5 -0
- package/templates/dashboard/package.json +75 -0
- package/templates/dashboard/playwright.config.ts +21 -0
- package/templates/dashboard/prettier.config.mjs +9 -0
- package/templates/dashboard/proxy.ts +36 -0
- package/templates/dashboard/src/app/api/auth/[...all]/route.ts +5 -0
- package/templates/dashboard/src/app/api/billing/checkout/route.ts +26 -0
- package/templates/dashboard/src/app/api/billing/portal/route.ts +25 -0
- package/templates/dashboard/src/app/api/email/test/route.ts +28 -0
- package/templates/dashboard/src/app/api/webhooks/polar/route.ts +5 -0
- package/templates/dashboard/src/app/api/webhooks/stripe/route.ts +5 -0
- package/templates/dashboard/src/app/billing/layout.tsx +22 -0
- package/templates/dashboard/src/app/billing/page.tsx +73 -0
- package/templates/dashboard/src/app/dashboard/layout.tsx +22 -0
- package/templates/dashboard/src/app/dashboard/page.tsx +104 -0
- package/templates/dashboard/src/app/email/layout.tsx +22 -0
- package/templates/dashboard/src/app/email/page.tsx +54 -0
- package/templates/dashboard/src/app/globals.css +1357 -0
- package/templates/dashboard/src/app/layout.tsx +25 -0
- package/templates/dashboard/src/app/page.tsx +154 -0
- package/templates/dashboard/src/app/settings/layout.tsx +22 -0
- package/templates/dashboard/src/app/settings/page.tsx +85 -0
- package/templates/dashboard/src/app/sign-in/page.tsx +47 -0
- package/templates/dashboard/src/app/sign-up/page.tsx +47 -0
- package/templates/dashboard/src/components/auth/email-auth-form.test.tsx +39 -0
- package/templates/dashboard/src/components/auth/email-auth-form.tsx +160 -0
- package/templates/dashboard/src/components/auth/sign-out-button.tsx +29 -0
- package/templates/dashboard/src/components/dashboard/shell.tsx +158 -0
- package/templates/dashboard/src/db/index.ts +16 -0
- package/templates/dashboard/src/db/schema/auth.ts +4 -0
- package/templates/dashboard/src/db/schema/index.ts +2 -0
- package/templates/dashboard/src/db/schema/projects.ts +17 -0
- package/templates/dashboard/src/db/seeds/index.ts +28 -0
- package/templates/dashboard/src/lib/auth-client.ts +5 -0
- package/templates/dashboard/src/lib/auth-session.ts +11 -0
- package/templates/dashboard/src/lib/auth.ts +41 -0
- package/templates/dashboard/src/lib/billing/index.ts +37 -0
- package/templates/dashboard/src/lib/billing/providers/polar.ts +80 -0
- package/templates/dashboard/src/lib/billing/providers/stripe.ts +77 -0
- package/templates/dashboard/src/lib/billing/types.ts +25 -0
- package/templates/dashboard/src/lib/email/index.ts +19 -0
- package/templates/dashboard/src/lib/email/templates.test.ts +15 -0
- package/templates/dashboard/src/lib/email/templates.ts +40 -0
- package/templates/dashboard/src/lib/env.ts +88 -0
- package/templates/dashboard/tsconfig.json +34 -0
- package/templates/dashboard/vitest.config.ts +19 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
|
|
7
|
+
import { authClient } from "@/lib/auth-client";
|
|
8
|
+
|
|
9
|
+
type EmailAuthFormProps = {
|
|
10
|
+
mode: "sign-in" | "sign-up";
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function EmailAuthForm({ mode }: EmailAuthFormProps) {
|
|
14
|
+
const isSignUp = mode === "sign-up";
|
|
15
|
+
const router = useRouter();
|
|
16
|
+
const [error, setError] = useState<string | null>(null);
|
|
17
|
+
const [isPending, setIsPending] = useState(false);
|
|
18
|
+
|
|
19
|
+
async function handleSubmit(formData: FormData) {
|
|
20
|
+
const email = String(formData.get("email") ?? "");
|
|
21
|
+
const password = String(formData.get("password") ?? "");
|
|
22
|
+
const name = String(formData.get("name") ?? "");
|
|
23
|
+
|
|
24
|
+
setIsPending(true);
|
|
25
|
+
setError(null);
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const result = isSignUp
|
|
29
|
+
? await authClient.signUp.email({
|
|
30
|
+
email,
|
|
31
|
+
password,
|
|
32
|
+
name
|
|
33
|
+
})
|
|
34
|
+
: await authClient.signIn.email({
|
|
35
|
+
email,
|
|
36
|
+
password
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
if (result.error) {
|
|
40
|
+
setError(result.error.message ?? "Authentication failed.");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
router.push("/dashboard");
|
|
45
|
+
router.refresh();
|
|
46
|
+
} catch {
|
|
47
|
+
setError("Authentication failed.");
|
|
48
|
+
} finally {
|
|
49
|
+
setIsPending(false);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<form
|
|
55
|
+
action={(formData) => {
|
|
56
|
+
void handleSubmit(formData);
|
|
57
|
+
}}
|
|
58
|
+
className="auth-card"
|
|
59
|
+
>
|
|
60
|
+
<div className="auth-copy">
|
|
61
|
+
<p className="eyebrow">{isSignUp ? "Create account" : "Welcome back"}</p>
|
|
62
|
+
<h1>{isSignUp ? "Create your account" : "Sign in to your workspace"}</h1>
|
|
63
|
+
<p className="lede">
|
|
64
|
+
{isSignUp
|
|
65
|
+
? "Start from a minimal SaaS baseline with auth, billing, and strong project structure."
|
|
66
|
+
: "Use your email and password to continue into the app."}
|
|
67
|
+
</p>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<div className="auth-fields">
|
|
71
|
+
{isSignUp ? (
|
|
72
|
+
<label className="field">
|
|
73
|
+
<span>Name</span>
|
|
74
|
+
<input name="name" type="text" placeholder="Ada Lovelace" required />
|
|
75
|
+
</label>
|
|
76
|
+
) : null}
|
|
77
|
+
|
|
78
|
+
<label className="field">
|
|
79
|
+
<span>Email</span>
|
|
80
|
+
<input name="email" type="email" placeholder="you@example.com" required />
|
|
81
|
+
</label>
|
|
82
|
+
|
|
83
|
+
<label className="field">
|
|
84
|
+
<span>Password</span>
|
|
85
|
+
<input
|
|
86
|
+
name="password"
|
|
87
|
+
type="password"
|
|
88
|
+
placeholder="At least 8 characters"
|
|
89
|
+
minLength={8}
|
|
90
|
+
required
|
|
91
|
+
/>
|
|
92
|
+
</label>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
{error ? <p className="form-error">{error}</p> : null}
|
|
96
|
+
|
|
97
|
+
<button type="submit" className="auth-button" disabled={isPending}>
|
|
98
|
+
{isPending ? "Working..." : isSignUp ? "Create account" : "Sign in"}
|
|
99
|
+
</button>
|
|
100
|
+
|
|
101
|
+
<p className="auth-switch">
|
|
102
|
+
{isSignUp ? "Already have an account?" : "Need an account?"}{" "}
|
|
103
|
+
<Link href={isSignUp ? "/sign-in" : "/sign-up"}>
|
|
104
|
+
{isSignUp ? "Sign in" : "Create one"}
|
|
105
|
+
</Link>
|
|
106
|
+
</p>
|
|
107
|
+
</form>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
|
|
6
|
+
import { authClient } from "@/lib/auth-client";
|
|
7
|
+
|
|
8
|
+
export function SignOutButton() {
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
const [isPending, setIsPending] = useState(false);
|
|
11
|
+
|
|
12
|
+
async function handleSignOut() {
|
|
13
|
+
setIsPending(true);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
await authClient.signOut();
|
|
17
|
+
router.push("/sign-in");
|
|
18
|
+
router.refresh();
|
|
19
|
+
} finally {
|
|
20
|
+
setIsPending(false);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<button type="button" className="btn-ghost" onClick={() => void handleSignOut()}>
|
|
26
|
+
{isPending ? "Signing out..." : "Sign out"}
|
|
27
|
+
</button>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { drizzle } from "__DRIZZLE_DRIVER_IMPORT__";
|
|
4
|
+
__DATABASE_CLIENT_IMPORT__
|
|
5
|
+
|
|
6
|
+
import { env } from "@/lib/env";
|
|
7
|
+
|
|
8
|
+
__DATABASE_GLOBAL_BLOCK__
|
|
9
|
+
|
|
10
|
+
const client = __DATABASE_CLIENT_EXPRESSION__;
|
|
11
|
+
|
|
12
|
+
if (env.NODE_ENV !== "production") {
|
|
13
|
+
__DATABASE_DEV_CACHE__
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const db = __DATABASE_DRIZZLE_EXPRESSION__;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
export const projects = pgTable("projects", {
|
|
4
|
+
id: text("id").primaryKey(),
|
|
5
|
+
name: text("name").notNull(),
|
|
6
|
+
slug: text("slug").notNull().unique(),
|
|
7
|
+
createdAt: timestamp("created_at", {
|
|
8
|
+
withTimezone: true
|
|
9
|
+
})
|
|
10
|
+
.defaultNow()
|
|
11
|
+
.notNull(),
|
|
12
|
+
updatedAt: timestamp("updated_at", {
|
|
13
|
+
withTimezone: true
|
|
14
|
+
})
|
|
15
|
+
.defaultNow()
|
|
16
|
+
.notNull()
|
|
17
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { db } from "@/db";
|
|
2
|
+
import { projects } from "@/db/schema";
|
|
3
|
+
|
|
4
|
+
const shouldSeedDemoData = ["yes"].includes("__SEED_DEMO_DATA__");
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
if (!shouldSeedDemoData) {
|
|
8
|
+
console.log("Demo seed data disabled.");
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
await db
|
|
13
|
+
.insert(projects)
|
|
14
|
+
.values({
|
|
15
|
+
id: "project-demo",
|
|
16
|
+
name: "Demo Project",
|
|
17
|
+
slug: "demo-project"
|
|
18
|
+
})
|
|
19
|
+
.onConflictDoNothing();
|
|
20
|
+
|
|
21
|
+
console.log("Database seed complete.");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
main().catch((error) => {
|
|
25
|
+
console.error("Database seed failed.");
|
|
26
|
+
console.error(error);
|
|
27
|
+
process.exit(1);
|
|
28
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { betterAuth } from "better-auth";
|
|
4
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
5
|
+
|
|
6
|
+
import { db } from "../db";
|
|
7
|
+
import { env } from "./env";
|
|
8
|
+
import * as schema from "../db/schema";
|
|
9
|
+
|
|
10
|
+
export const auth = betterAuth({
|
|
11
|
+
appName: "__PROJECT_NAME__",
|
|
12
|
+
baseURL: env.BETTER_AUTH_URL,
|
|
13
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
14
|
+
trustedOrigins: [env.NEXT_PUBLIC_APP_URL],
|
|
15
|
+
database: drizzleAdapter(db, {
|
|
16
|
+
provider: "pg",
|
|
17
|
+
schema
|
|
18
|
+
}),
|
|
19
|
+
emailAndPassword: {
|
|
20
|
+
enabled: true,
|
|
21
|
+
autoSignIn: true
|
|
22
|
+
}
|
|
23
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { env } from "@/lib/env";
|
|
4
|
+
|
|
5
|
+
__BILLING_PROVIDER_IMPORTS__
|
|
6
|
+
|
|
7
|
+
import type { BillingProvider, BillingProviderName } from "./types";
|
|
8
|
+
|
|
9
|
+
export function getBillingProviderName(input?: string): BillingProviderName {
|
|
10
|
+
if (input === "stripe" || input === "polar") {
|
|
11
|
+
return input;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (env.BILLING_PROVIDER === "stripe" || env.BILLING_PROVIDER === "polar") {
|
|
15
|
+
return env.BILLING_PROVIDER;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (env.BILLING_PROVIDER === "both") {
|
|
19
|
+
return "stripe";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
throw new Error("Billing is disabled for this project.");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getBillingProvider(input?: string): BillingProvider {
|
|
26
|
+
const provider = getBillingProviderName(input);
|
|
27
|
+
const billingProviderFactories: Partial<Record<BillingProviderName, () => BillingProvider>> = {
|
|
28
|
+
__BILLING_PROVIDER_FACTORIES__
|
|
29
|
+
};
|
|
30
|
+
const factory = billingProviderFactories[provider];
|
|
31
|
+
|
|
32
|
+
if (!factory) {
|
|
33
|
+
throw new Error(`Billing provider is not installed: ${provider}.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return factory();
|
|
37
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { env } from "@/lib/env";
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
BillingProvider,
|
|
7
|
+
CheckoutSessionInput,
|
|
8
|
+
PortalSessionInput,
|
|
9
|
+
WebhookInput
|
|
10
|
+
} from "../types";
|
|
11
|
+
|
|
12
|
+
function getPolarApiBaseUrl() {
|
|
13
|
+
return env.POLAR_SERVER === "production"
|
|
14
|
+
? "https://api.polar.sh/v1"
|
|
15
|
+
: "https://sandbox-api.polar.sh/v1";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function polarFetch<T>(pathname: string, body: Record<string, unknown>) {
|
|
19
|
+
if (!env.POLAR_ACCESS_TOKEN) {
|
|
20
|
+
throw new Error("POLAR_ACCESS_TOKEN is required for Polar billing.");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const response = await fetch(`${getPolarApiBaseUrl()}${pathname}`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: {
|
|
26
|
+
Authorization: `Bearer ${env.POLAR_ACCESS_TOKEN}`,
|
|
27
|
+
"Content-Type": "application/json"
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify(body)
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
const message = await response.text();
|
|
34
|
+
throw new Error(`Polar request failed: ${message}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (await response.json()) as T;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class PolarBillingProvider implements BillingProvider {
|
|
41
|
+
async createCheckoutSession(input: CheckoutSessionInput) {
|
|
42
|
+
if (!env.POLAR_PRODUCT_ID) {
|
|
43
|
+
throw new Error("POLAR_PRODUCT_ID is required for Polar checkout.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const checkout = await polarFetch<{ url?: string }>("/checkouts", {
|
|
47
|
+
products: [env.POLAR_PRODUCT_ID],
|
|
48
|
+
external_customer_id: input.userId,
|
|
49
|
+
customer_email: input.customerEmail,
|
|
50
|
+
success_url: input.successUrl,
|
|
51
|
+
metadata: {
|
|
52
|
+
userId: input.userId
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (!checkout.url) {
|
|
57
|
+
throw new Error("Polar did not return a checkout URL.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { url: checkout.url };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async createCustomerPortalSession(input: PortalSessionInput) {
|
|
64
|
+
const session = await polarFetch<{ customer_portal_url?: string }>("/customer-sessions/", {
|
|
65
|
+
external_customer_id: input.userId
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!session.customer_portal_url) {
|
|
69
|
+
throw new Error("Polar did not return a customer portal URL.");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { url: session.customer_portal_url };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async handleWebhook(_input: WebhookInput) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
"Polar webhook verification is intentionally left for follow-up implementation after confirming your webhook signing strategy."
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import Stripe from "stripe";
|
|
4
|
+
|
|
5
|
+
import { env } from "@/lib/env";
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
BillingProvider,
|
|
9
|
+
CheckoutSessionInput,
|
|
10
|
+
PortalSessionInput,
|
|
11
|
+
WebhookInput
|
|
12
|
+
} from "../types";
|
|
13
|
+
|
|
14
|
+
let stripeClient: Stripe | null = null;
|
|
15
|
+
|
|
16
|
+
function getStripe() {
|
|
17
|
+
if (!env.STRIPE_SECRET_KEY) {
|
|
18
|
+
throw new Error("STRIPE_SECRET_KEY is required for Stripe billing.");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
stripeClient ??= new Stripe(env.STRIPE_SECRET_KEY);
|
|
22
|
+
return stripeClient;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class StripeBillingProvider implements BillingProvider {
|
|
26
|
+
async createCheckoutSession(input: CheckoutSessionInput) {
|
|
27
|
+
if (!env.STRIPE_PRICE_ID) {
|
|
28
|
+
throw new Error("STRIPE_PRICE_ID is required for Stripe checkout.");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const stripe = getStripe();
|
|
32
|
+
const session = await stripe.checkout.sessions.create({
|
|
33
|
+
mode: "subscription",
|
|
34
|
+
line_items: [
|
|
35
|
+
{
|
|
36
|
+
price: env.STRIPE_PRICE_ID,
|
|
37
|
+
quantity: 1
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
customer_email: input.customerEmail,
|
|
41
|
+
success_url: input.successUrl,
|
|
42
|
+
cancel_url: input.cancelUrl,
|
|
43
|
+
allow_promotion_codes: true,
|
|
44
|
+
metadata: {
|
|
45
|
+
userId: input.userId
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!session.url) {
|
|
50
|
+
throw new Error("Stripe did not return a checkout URL.");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { url: session.url };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async createCustomerPortalSession(
|
|
57
|
+
_input: PortalSessionInput
|
|
58
|
+
): Promise<{ url: string }> {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"Stripe portal requires a persisted Stripe customer ID. Add customer mapping before enabling portal access."
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async handleWebhook(input: WebhookInput) {
|
|
65
|
+
if (!env.STRIPE_WEBHOOK_SECRET) {
|
|
66
|
+
throw new Error("STRIPE_WEBHOOK_SECRET is required for Stripe webhooks.");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const signature = input.headers.get("stripe-signature");
|
|
70
|
+
if (!signature) {
|
|
71
|
+
throw new Error("Missing Stripe signature header.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stripe = getStripe();
|
|
75
|
+
stripe.webhooks.constructEvent(input.rawBody, signature, env.STRIPE_WEBHOOK_SECRET);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type BillingProviderName = "stripe" | "polar";
|
|
2
|
+
|
|
3
|
+
export type CheckoutSessionInput = {
|
|
4
|
+
customerEmail: string;
|
|
5
|
+
successUrl: string;
|
|
6
|
+
cancelUrl: string;
|
|
7
|
+
userId: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type PortalSessionInput = {
|
|
11
|
+
customerEmail: string;
|
|
12
|
+
returnUrl: string;
|
|
13
|
+
userId: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type WebhookInput = {
|
|
17
|
+
headers: Headers;
|
|
18
|
+
rawBody: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface BillingProvider {
|
|
22
|
+
createCheckoutSession(input: CheckoutSessionInput): Promise<{ url: string }>;
|
|
23
|
+
createCustomerPortalSession(input: PortalSessionInput): Promise<{ url: string }>;
|
|
24
|
+
handleWebhook(input: WebhookInput): Promise<void>;
|
|
25
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
__EMAIL_PROVIDER_IMPORTS__
|
|
4
|
+
|
|
5
|
+
__EMAIL_ENV_IMPORT__
|
|
6
|
+
|
|
7
|
+
import type { EmailTemplateResult } from "./templates";
|
|
8
|
+
|
|
9
|
+
__EMAIL_PROVIDER_SETUP__
|
|
10
|
+
|
|
11
|
+
__EMAIL_FROM_ADDRESS_HELPER__
|
|
12
|
+
|
|
13
|
+
export async function sendEmail(__EMAIL_SEND_SIGNATURE__: {
|
|
14
|
+
to: string | string[];
|
|
15
|
+
template: EmailTemplateResult;
|
|
16
|
+
}) {
|
|
17
|
+
void __EMAIL_SEND_SIGNATURE__;
|
|
18
|
+
__EMAIL_SEND_IMPLEMENTATION__
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createWelcomeEmailTemplate } from "@/lib/email/templates";
|
|
4
|
+
|
|
5
|
+
describe("createWelcomeEmailTemplate", () => {
|
|
6
|
+
it("includes the app name in the subject", () => {
|
|
7
|
+
const template = createWelcomeEmailTemplate({
|
|
8
|
+
appName: "Skit",
|
|
9
|
+
recipientName: "Ada"
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(template.subject).toContain("Skit");
|
|
13
|
+
expect(template.text).toContain("Ada");
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export type EmailTemplateResult = {
|
|
2
|
+
subject: string;
|
|
3
|
+
text: string;
|
|
4
|
+
html: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function createWelcomeEmailTemplate(input: {
|
|
8
|
+
appName: string;
|
|
9
|
+
recipientName?: string | null;
|
|
10
|
+
}): EmailTemplateResult {
|
|
11
|
+
const greeting = input.recipientName ? `Hi ${input.recipientName},` : "Hi there,";
|
|
12
|
+
|
|
13
|
+
return {
|
|
14
|
+
subject: `Welcome to ${input.appName}`,
|
|
15
|
+
text: `${greeting}\n\nYour starter app is ready. Auth, billing, and the core project structure are already in place.\n\nNext step: sign in and continue building.\n`,
|
|
16
|
+
html: `<p>${greeting}</p><p>Your starter app is ready. Auth, billing, and the core project structure are already in place.</p><p>Next step: sign in and continue building.</p>`
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function createPasswordResetPlaceholderTemplate(input: {
|
|
21
|
+
appName: string;
|
|
22
|
+
resetUrl: string;
|
|
23
|
+
}): EmailTemplateResult {
|
|
24
|
+
return {
|
|
25
|
+
subject: `Reset your ${input.appName} password`,
|
|
26
|
+
text: `Use this link to reset your password: ${input.resetUrl}`,
|
|
27
|
+
html: `<p>Use this link to reset your password:</p><p><a href="${input.resetUrl}">${input.resetUrl}</a></p>`
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createBillingUpdatePlaceholderTemplate(input: {
|
|
32
|
+
appName: string;
|
|
33
|
+
provider: string;
|
|
34
|
+
}): EmailTemplateResult {
|
|
35
|
+
return {
|
|
36
|
+
subject: `${input.appName} billing update`,
|
|
37
|
+
text: `A billing event was received from ${input.provider}. Replace this placeholder with your real billing notification workflow.`,
|
|
38
|
+
html: `<p>A billing event was received from <strong>${input.provider}</strong>.</p><p>Replace this placeholder with your real billing notification workflow.</p>`
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
const envSchema = z.object({
|
|
6
|
+
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
|
7
|
+
NEXT_PUBLIC_APP_URL: z.string().url(),
|
|
8
|
+
BETTER_AUTH_URL: z.string().url(),
|
|
9
|
+
DATABASE_URL: z.string().min(1),
|
|
10
|
+
BETTER_AUTH_SECRET: z.string().min(1),
|
|
11
|
+
BILLING_PROVIDER: z.enum(["stripe", "polar", "both", "none"]).default("stripe"),
|
|
12
|
+
EMAIL_PROVIDER: z.enum(["resend", "none"]).default("resend"),
|
|
13
|
+
RESEND_API_KEY: z.string().min(1).optional(),
|
|
14
|
+
EMAIL_FROM: z.string().min(1).optional(),
|
|
15
|
+
STRIPE_PRICE_ID: z.string().min(1).optional(),
|
|
16
|
+
STRIPE_SECRET_KEY: z.string().min(1).optional(),
|
|
17
|
+
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(),
|
|
18
|
+
POLAR_ACCESS_TOKEN: z.string().min(1).optional(),
|
|
19
|
+
POLAR_ORGANIZATION_ID: z.string().min(1).optional(),
|
|
20
|
+
POLAR_PRODUCT_ID: z.string().min(1).optional(),
|
|
21
|
+
POLAR_SERVER: z.enum(["production", "sandbox"]).default("sandbox"),
|
|
22
|
+
POLAR_WEBHOOK_SECRET: z.string().min(1).optional()
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
type Env = z.infer<typeof envSchema>;
|
|
26
|
+
|
|
27
|
+
let cachedEnv: Env | null = null;
|
|
28
|
+
const isBuildRuntime =
|
|
29
|
+
process.env.npm_lifecycle_event === "build" ||
|
|
30
|
+
process.env.NEXT_PHASE === "phase-production-build";
|
|
31
|
+
|
|
32
|
+
function readRequiredValue(name: string, fallback: string) {
|
|
33
|
+
const value = process.env[name];
|
|
34
|
+
|
|
35
|
+
if (value) {
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (isBuildRuntime) {
|
|
40
|
+
return fallback;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function parseEnv(): Env {
|
|
47
|
+
return envSchema.parse({
|
|
48
|
+
NODE_ENV: process.env.NODE_ENV,
|
|
49
|
+
NEXT_PUBLIC_APP_URL: readRequiredValue("NEXT_PUBLIC_APP_URL", "http://localhost:3000"),
|
|
50
|
+
BETTER_AUTH_URL: readRequiredValue("BETTER_AUTH_URL", "http://localhost:3000"),
|
|
51
|
+
DATABASE_URL: readRequiredValue(
|
|
52
|
+
"DATABASE_URL",
|
|
53
|
+
"postgresql://postgres:postgres@localhost:5432/skit"
|
|
54
|
+
),
|
|
55
|
+
BETTER_AUTH_SECRET: readRequiredValue("BETTER_AUTH_SECRET", "build-placeholder-secret"),
|
|
56
|
+
BILLING_PROVIDER: process.env.BILLING_PROVIDER,
|
|
57
|
+
EMAIL_PROVIDER: process.env.EMAIL_PROVIDER,
|
|
58
|
+
RESEND_API_KEY: process.env.RESEND_API_KEY,
|
|
59
|
+
EMAIL_FROM: process.env.EMAIL_FROM,
|
|
60
|
+
STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID,
|
|
61
|
+
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
|
|
62
|
+
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
|
|
63
|
+
POLAR_ACCESS_TOKEN: process.env.POLAR_ACCESS_TOKEN,
|
|
64
|
+
POLAR_ORGANIZATION_ID: process.env.POLAR_ORGANIZATION_ID,
|
|
65
|
+
POLAR_PRODUCT_ID: process.env.POLAR_PRODUCT_ID,
|
|
66
|
+
POLAR_SERVER: process.env.POLAR_SERVER,
|
|
67
|
+
POLAR_WEBHOOK_SECRET: process.env.POLAR_WEBHOOK_SECRET
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getEnv(): Env {
|
|
72
|
+
cachedEnv ??= parseEnv();
|
|
73
|
+
return cachedEnv;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const env = new Proxy({} as Env, {
|
|
77
|
+
get(_target, property) {
|
|
78
|
+
return getEnv()[property as keyof Env];
|
|
79
|
+
}
|
|
80
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "es2022"],
|
|
5
|
+
"allowJs": false,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"exactOptionalPropertyTypes": true,
|
|
10
|
+
"noEmit": true,
|
|
11
|
+
"esModuleInterop": true,
|
|
12
|
+
"module": "esnext",
|
|
13
|
+
"moduleResolution": "bundler",
|
|
14
|
+
"baseUrl": ".",
|
|
15
|
+
"resolveJsonModule": true,
|
|
16
|
+
"isolatedModules": true,
|
|
17
|
+
"jsx": "react-jsx",
|
|
18
|
+
"incremental": true,
|
|
19
|
+
"verbatimModuleSyntax": true,
|
|
20
|
+
"moduleDetection": "force",
|
|
21
|
+
"plugins": [{ "name": "next" }],
|
|
22
|
+
"paths": {
|
|
23
|
+
"@/*": ["./src/*"]
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"include": [
|
|
27
|
+
"next-env.d.ts",
|
|
28
|
+
"**/*.ts",
|
|
29
|
+
"**/*.tsx",
|
|
30
|
+
".next/types/**/*.ts",
|
|
31
|
+
".next/dev/types/**/*.ts"
|
|
32
|
+
],
|
|
33
|
+
"exclude": ["node_modules"]
|
|
34
|
+
}
|