saas-init 1.0.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 +98 -0
- package/dist/index.js +765 -0
- package/package.json +68 -0
- package/templates/auth/clerk/app/sign-in/[[...sign-in]]/page.tsx +9 -0
- package/templates/auth/clerk/app/sign-up/[[...sign-up]]/page.tsx +9 -0
- package/templates/auth/clerk/middleware.ts +12 -0
- package/templates/auth/nextauth/app/api/auth/route.ts +3 -0
- package/templates/auth/nextauth/auth.ts +12 -0
- package/templates/auth/supabase/middleware.ts +59 -0
- package/templates/auth/supabase/utils/supabase/client.ts +12 -0
- package/templates/auth/supabase/utils/supabase/server.ts +35 -0
- package/templates/base/app/globals.css +87 -0
- package/templates/base/app/layout.tsx +19 -0
- package/templates/base/app/page.tsx +7 -0
- package/templates/base/components.json +21 -0
- package/templates/base/lib/utils.ts +6 -0
- package/templates/base/next.config.ts +7 -0
- package/templates/base/package.json +23 -0
- package/templates/base/postcss.config.mjs +5 -0
- package/templates/base/tsconfig.json +27 -0
- package/templates/database/postgres/db/index.ts +12 -0
- package/templates/database/postgres/db/schema.ts +7 -0
- package/templates/database/postgres/drizzle.config.ts +10 -0
- package/templates/database/sqlite/db/index.ts +7 -0
- package/templates/database/sqlite/db/schema.ts +7 -0
- package/templates/database/sqlite/drizzle.config.ts +10 -0
- package/templates/database/supabase/utils/supabase/client.ts +12 -0
- package/templates/database/supabase/utils/supabase/db.ts +10 -0
- package/templates/docker/.dockerignore +5 -0
- package/templates/docker/Dockerfile +25 -0
- package/templates/docker/docker-compose.yml +22 -0
- package/templates/email/postmark/lib/email.ts +17 -0
- package/templates/email/resend/lib/email.ts +20 -0
- package/templates/github/.github/workflows/ci.yml +55 -0
- package/templates/landing/app/page.tsx +21 -0
- package/templates/landing/components/Footer.tsx +37 -0
- package/templates/landing/components/Hero.tsx +29 -0
- package/templates/landing/components/ProblemAgitate.tsx +34 -0
- package/templates/landing/components/SecondaryCTA.tsx +20 -0
- package/templates/landing/components/SocialProof.tsx +43 -0
- package/templates/landing/components/Transformation.tsx +48 -0
- package/templates/landing/components/ValueStack.tsx +54 -0
- package/templates/payments/lemonsqueezy/app/api/webhooks/lemonsqueezy/route.ts +58 -0
- package/templates/payments/lemonsqueezy/lib/lemonsqueezy.ts +13 -0
- package/templates/payments/stripe/app/api/webhooks/stripe/route.ts +46 -0
- package/templates/payments/stripe/lib/stripe.ts +10 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
name: CI — {{name}}
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
pull_request:
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
steps:
|
|
13
|
+
- uses: actions/checkout@v4
|
|
14
|
+
- uses: pnpm/action-setup@v3
|
|
15
|
+
with:
|
|
16
|
+
version: latest
|
|
17
|
+
- uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version: '20'
|
|
20
|
+
cache: 'pnpm'
|
|
21
|
+
- run: pnpm install --frozen-lockfile
|
|
22
|
+
- run: pnpm test
|
|
23
|
+
|
|
24
|
+
build:
|
|
25
|
+
needs: test
|
|
26
|
+
runs-on: ubuntu-latest
|
|
27
|
+
steps:
|
|
28
|
+
- uses: actions/checkout@v4
|
|
29
|
+
- uses: pnpm/action-setup@v3
|
|
30
|
+
with:
|
|
31
|
+
version: latest
|
|
32
|
+
- uses: actions/setup-node@v4
|
|
33
|
+
with:
|
|
34
|
+
node-version: '20'
|
|
35
|
+
cache: 'pnpm'
|
|
36
|
+
- run: pnpm install --frozen-lockfile
|
|
37
|
+
- run: pnpm build
|
|
38
|
+
- uses: actions/upload-artifact@v4
|
|
39
|
+
with:
|
|
40
|
+
name: next-build
|
|
41
|
+
path: .next
|
|
42
|
+
retention-days: 1
|
|
43
|
+
|
|
44
|
+
deploy:
|
|
45
|
+
needs: build
|
|
46
|
+
runs-on: ubuntu-latest
|
|
47
|
+
if: github.ref == 'refs/heads/main'
|
|
48
|
+
steps:
|
|
49
|
+
- uses: actions/checkout@v4
|
|
50
|
+
- uses: amondnet/vercel-action@v25
|
|
51
|
+
with:
|
|
52
|
+
vercel-token: ${{ secrets.VERCEL_TOKEN }}
|
|
53
|
+
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
|
|
54
|
+
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
|
|
55
|
+
vercel-args: '--prod'
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import Hero from '@/components/Hero';
|
|
2
|
+
import ProblemAgitate from '@/components/ProblemAgitate';
|
|
3
|
+
import ValueStack from '@/components/ValueStack';
|
|
4
|
+
import SocialProof from '@/components/SocialProof';
|
|
5
|
+
import Transformation from '@/components/Transformation';
|
|
6
|
+
import SecondaryCTA from '@/components/SecondaryCTA';
|
|
7
|
+
import Footer from '@/components/Footer';
|
|
8
|
+
|
|
9
|
+
export default function Home() {
|
|
10
|
+
return (
|
|
11
|
+
<main>
|
|
12
|
+
<Hero />
|
|
13
|
+
<ProblemAgitate />
|
|
14
|
+
<ValueStack />
|
|
15
|
+
<SocialProof />
|
|
16
|
+
<Transformation />
|
|
17
|
+
<SecondaryCTA />
|
|
18
|
+
<Footer />
|
|
19
|
+
</main>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export default function Footer() {
|
|
2
|
+
return (
|
|
3
|
+
<footer className="border-t py-12 px-4">
|
|
4
|
+
<div className="max-w-5xl mx-auto">
|
|
5
|
+
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
|
6
|
+
<span className="text-xl font-bold">{{name}}</span>
|
|
7
|
+
<nav className="flex gap-6 text-sm text-muted-foreground">
|
|
8
|
+
<a href="#" className="hover:text-foreground transition-colors">Home</a>
|
|
9
|
+
<a href="#" className="hover:text-foreground transition-colors">Features</a>
|
|
10
|
+
<a href="#" className="hover:text-foreground transition-colors">Pricing</a>
|
|
11
|
+
<a href="#" className="hover:text-foreground transition-colors">Blog</a>
|
|
12
|
+
</nav>
|
|
13
|
+
<div className="flex gap-4">
|
|
14
|
+
<a href="#" aria-label="Twitter/X" className="text-muted-foreground hover:text-foreground transition-colors">
|
|
15
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
16
|
+
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
|
17
|
+
</svg>
|
|
18
|
+
</a>
|
|
19
|
+
<a href="#" aria-label="GitHub" className="text-muted-foreground hover:text-foreground transition-colors">
|
|
20
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
21
|
+
<path d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"/>
|
|
22
|
+
</svg>
|
|
23
|
+
</a>
|
|
24
|
+
<a href="#" aria-label="LinkedIn" className="text-muted-foreground hover:text-foreground transition-colors">
|
|
25
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
26
|
+
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
|
27
|
+
</svg>
|
|
28
|
+
</a>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
<div className="mt-8 text-center text-sm text-muted-foreground">
|
|
32
|
+
© {{name}}. All rights reserved.
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</footer>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export default function Hero() {
|
|
2
|
+
return (
|
|
3
|
+
<section className="flex flex-col items-center text-center py-24 px-4">
|
|
4
|
+
<span className="inline-block mb-4 px-3 py-1 text-sm font-medium rounded-full bg-primary/10 text-primary">
|
|
5
|
+
{{name}}
|
|
6
|
+
</span>
|
|
7
|
+
<h1 className="text-5xl font-bold tracking-tight mb-4">
|
|
8
|
+
{{name}} — {{tagline}}
|
|
9
|
+
</h1>
|
|
10
|
+
<p className="text-xl text-muted-foreground max-w-2xl mb-8">
|
|
11
|
+
{{problemStatement}}
|
|
12
|
+
</p>
|
|
13
|
+
<a
|
|
14
|
+
href="#"
|
|
15
|
+
className="inline-flex items-center justify-center px-8 py-3 text-base font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
16
|
+
>
|
|
17
|
+
Get started
|
|
18
|
+
</a>
|
|
19
|
+
<div className="flex items-center gap-3 mt-8 text-sm text-muted-foreground">
|
|
20
|
+
<div className="flex -space-x-2">
|
|
21
|
+
<div className="w-8 h-8 rounded-full bg-muted border-2 border-background" />
|
|
22
|
+
<div className="w-8 h-8 rounded-full bg-muted border-2 border-background" />
|
|
23
|
+
<div className="w-8 h-8 rounded-full bg-muted border-2 border-background" />
|
|
24
|
+
</div>
|
|
25
|
+
<span>Trusted by companies like yours</span>
|
|
26
|
+
</div>
|
|
27
|
+
</section>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export default function ProblemAgitate() {
|
|
2
|
+
return (
|
|
3
|
+
<section className="py-20 px-4 bg-muted/50">
|
|
4
|
+
<div className="max-w-4xl mx-auto">
|
|
5
|
+
<h2 className="text-3xl font-bold text-center mb-12">
|
|
6
|
+
You're probably dealing with…
|
|
7
|
+
</h2>
|
|
8
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
9
|
+
<div className="problem-card p-6 rounded-lg bg-background border">
|
|
10
|
+
<span className="text-2xl mb-3 block">▸</span>
|
|
11
|
+
<p className="font-bold mb-2">{{problem1Title}}</p>
|
|
12
|
+
<p className="text-sm text-muted-foreground">
|
|
13
|
+
{{problem1Body}}
|
|
14
|
+
</p>
|
|
15
|
+
</div>
|
|
16
|
+
<div className="problem-card p-6 rounded-lg bg-background border">
|
|
17
|
+
<span className="text-2xl mb-3 block">▸</span>
|
|
18
|
+
<p className="font-bold mb-2">{{problem2Title}}</p>
|
|
19
|
+
<p className="text-sm text-muted-foreground">
|
|
20
|
+
{{problem2Body}}
|
|
21
|
+
</p>
|
|
22
|
+
</div>
|
|
23
|
+
<div className="problem-card p-6 rounded-lg bg-background border">
|
|
24
|
+
<span className="text-2xl mb-3 block">▸</span>
|
|
25
|
+
<p className="font-bold mb-2">{{problem3Title}}</p>
|
|
26
|
+
<p className="text-sm text-muted-foreground">
|
|
27
|
+
{{problem3Body}}
|
|
28
|
+
</p>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
</section>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export default function SecondaryCTA() {
|
|
2
|
+
return (
|
|
3
|
+
<section className="py-24 px-4 bg-muted/50">
|
|
4
|
+
<div className="max-w-xl mx-auto flex flex-col items-center text-center gap-6">
|
|
5
|
+
<div className="flex -space-x-3">
|
|
6
|
+
<div className="w-12 h-12 rounded-full bg-muted border-2 border-background" />
|
|
7
|
+
<div className="w-12 h-12 rounded-full bg-muted border-2 border-background" />
|
|
8
|
+
<div className="w-12 h-12 rounded-full bg-muted border-2 border-background" />
|
|
9
|
+
</div>
|
|
10
|
+
<p className="text-2xl font-semibold">Ready to join them?</p>
|
|
11
|
+
<a
|
|
12
|
+
href="#"
|
|
13
|
+
className="inline-flex items-center justify-center px-8 py-4 text-base font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
14
|
+
>
|
|
15
|
+
Yes — get started with {{name}}
|
|
16
|
+
</a>
|
|
17
|
+
</div>
|
|
18
|
+
</section>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export default function SocialProof() {
|
|
2
|
+
return (
|
|
3
|
+
<section className="py-20 px-4 bg-muted/50">
|
|
4
|
+
<div className="max-w-4xl mx-auto">
|
|
5
|
+
<h2 className="text-3xl font-bold text-center mb-12">
|
|
6
|
+
What our customers say
|
|
7
|
+
</h2>
|
|
8
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
9
|
+
<div className="p-6 rounded-lg bg-background border flex flex-col gap-4">
|
|
10
|
+
<div className="w-10 h-10 rounded-full bg-muted" />
|
|
11
|
+
<blockquote className="text-sm text-muted-foreground italic">
|
|
12
|
+
“{{testimonial1Quote}}”
|
|
13
|
+
</blockquote>
|
|
14
|
+
<div>
|
|
15
|
+
<p className="font-semibold text-sm">{{testimonial1Name}}</p>
|
|
16
|
+
<p className="text-xs text-muted-foreground">{{testimonial1Role}}</p>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
<div className="p-6 rounded-lg bg-background border flex flex-col gap-4">
|
|
20
|
+
<div className="w-10 h-10 rounded-full bg-muted" />
|
|
21
|
+
<blockquote className="text-sm text-muted-foreground italic">
|
|
22
|
+
“{{testimonial2Quote}}”
|
|
23
|
+
</blockquote>
|
|
24
|
+
<div>
|
|
25
|
+
<p className="font-semibold text-sm">{{testimonial2Name}}</p>
|
|
26
|
+
<p className="text-xs text-muted-foreground">{{testimonial2Role}}</p>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
<div className="p-6 rounded-lg bg-background border flex flex-col gap-4">
|
|
30
|
+
<div className="w-10 h-10 rounded-full bg-muted" />
|
|
31
|
+
<blockquote className="text-sm text-muted-foreground italic">
|
|
32
|
+
“{{testimonial3Quote}}”
|
|
33
|
+
</blockquote>
|
|
34
|
+
<div>
|
|
35
|
+
<p className="font-semibold text-sm">{{testimonial3Name}}</p>
|
|
36
|
+
<p className="text-xs text-muted-foreground">{{testimonial3Role}}</p>
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
</section>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export default function Transformation() {
|
|
2
|
+
return (
|
|
3
|
+
<section className="py-24 px-4">
|
|
4
|
+
<div className="max-w-5xl mx-auto">
|
|
5
|
+
<h2 className="text-3xl font-bold text-center mb-16">
|
|
6
|
+
Your journey with {{name}}
|
|
7
|
+
</h2>
|
|
8
|
+
<div className="flex flex-col md:flex-row items-start gap-4">
|
|
9
|
+
<div className="flex-1 rounded-lg border bg-card p-6 text-center">
|
|
10
|
+
<div className="text-4xl font-bold text-primary mb-2">①</div>
|
|
11
|
+
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Day 1</div>
|
|
12
|
+
<h3 className="text-lg font-semibold mb-2">Quick Win</h3>
|
|
13
|
+
<p className="text-sm text-muted-foreground">
|
|
14
|
+
{{stage1Body}}
|
|
15
|
+
</p>
|
|
16
|
+
</div>
|
|
17
|
+
<div className="hidden md:flex items-center self-center text-muted-foreground text-2xl">→</div>
|
|
18
|
+
<div className="flex-1 rounded-lg border bg-card p-6 text-center">
|
|
19
|
+
<div className="text-4xl font-bold text-primary mb-2">②</div>
|
|
20
|
+
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Week 1</div>
|
|
21
|
+
<h3 className="text-lg font-semibold mb-2">Compound</h3>
|
|
22
|
+
<p className="text-sm text-muted-foreground">
|
|
23
|
+
{{stage2Body}}
|
|
24
|
+
</p>
|
|
25
|
+
</div>
|
|
26
|
+
<div className="hidden md:flex items-center self-center text-muted-foreground text-2xl">→</div>
|
|
27
|
+
<div className="flex-1 rounded-lg border bg-card p-6 text-center">
|
|
28
|
+
<div className="text-4xl font-bold text-primary mb-2">③</div>
|
|
29
|
+
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Month 1</div>
|
|
30
|
+
<h3 className="text-lg font-semibold mb-2">Advantage</h3>
|
|
31
|
+
<p className="text-sm text-muted-foreground">
|
|
32
|
+
{{stage3Body}}
|
|
33
|
+
</p>
|
|
34
|
+
</div>
|
|
35
|
+
<div className="hidden md:flex items-center self-center text-muted-foreground text-2xl">→</div>
|
|
36
|
+
<div className="flex-1 rounded-lg border bg-card p-6 text-center">
|
|
37
|
+
<div className="text-4xl font-bold text-primary mb-2">④</div>
|
|
38
|
+
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-1">Month 3+</div>
|
|
39
|
+
<h3 className="text-lg font-semibold mb-2">10x</h3>
|
|
40
|
+
<p className="text-sm text-muted-foreground">
|
|
41
|
+
{{stage4Body}}
|
|
42
|
+
</p>
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
</section>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export default function ValueStack() {
|
|
2
|
+
return (
|
|
3
|
+
<section className="py-20 px-4">
|
|
4
|
+
<div className="max-w-2xl mx-auto">
|
|
5
|
+
<h2 className="text-3xl font-bold text-center mb-12">
|
|
6
|
+
Everything you need
|
|
7
|
+
</h2>
|
|
8
|
+
<div className="space-y-4 mb-8">
|
|
9
|
+
<div className="flex items-center justify-between p-4 rounded-lg border">
|
|
10
|
+
<div className="flex items-center gap-3">
|
|
11
|
+
<span className="text-xl">⚡</span>
|
|
12
|
+
<span className="font-medium">{{feature1}}</span>
|
|
13
|
+
</div>
|
|
14
|
+
<span className="text-sm text-muted-foreground">valued at $49</span>
|
|
15
|
+
</div>
|
|
16
|
+
<div className="flex items-center justify-between p-4 rounded-lg border">
|
|
17
|
+
<div className="flex items-center gap-3">
|
|
18
|
+
<span className="text-xl">🔒</span>
|
|
19
|
+
<span className="font-medium">{{feature2}}</span>
|
|
20
|
+
</div>
|
|
21
|
+
<span className="text-sm text-muted-foreground">valued at $79</span>
|
|
22
|
+
</div>
|
|
23
|
+
<div className="flex items-center justify-between p-4 rounded-lg border">
|
|
24
|
+
<div className="flex items-center gap-3">
|
|
25
|
+
<span className="text-xl">📈</span>
|
|
26
|
+
<span className="font-medium">{{feature3}}</span>
|
|
27
|
+
</div>
|
|
28
|
+
<span className="text-sm text-muted-foreground">valued at $99</span>
|
|
29
|
+
</div>
|
|
30
|
+
<div className="flex items-center justify-between p-4 rounded-lg border">
|
|
31
|
+
<div className="flex items-center gap-3">
|
|
32
|
+
<span className="text-xl">🚀</span>
|
|
33
|
+
<span className="font-medium">Production-ready deployment</span>
|
|
34
|
+
</div>
|
|
35
|
+
<span className="text-sm text-muted-foreground">valued at $129</span>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
<div className="text-center p-4 rounded-lg bg-muted/50 mb-4">
|
|
39
|
+
<p className="text-muted-foreground">Total value: <span className="font-bold text-foreground">$356</span></p>
|
|
40
|
+
</div>
|
|
41
|
+
<div className="text-center p-6 rounded-lg border-2 border-primary">
|
|
42
|
+
<p className="text-lg font-medium mb-1">Yours today for just</p>
|
|
43
|
+
<p className="text-4xl font-bold mb-4">${{price}}</p>
|
|
44
|
+
<a
|
|
45
|
+
href="#"
|
|
46
|
+
className="inline-flex items-center justify-center px-8 py-3 text-base font-medium rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
|
47
|
+
>
|
|
48
|
+
Get started now
|
|
49
|
+
</a>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
</section>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import crypto from 'crypto'
|
|
3
|
+
|
|
4
|
+
export async function POST(req: NextRequest) {
|
|
5
|
+
const body = await req.text()
|
|
6
|
+
const signature = req.headers.get('x-signature')
|
|
7
|
+
|
|
8
|
+
if (!signature) {
|
|
9
|
+
return NextResponse.json({ error: 'Missing x-signature header' }, { status: 400 })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET
|
|
13
|
+
if (!secret) {
|
|
14
|
+
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const hmac = crypto.createHmac('sha256', secret)
|
|
18
|
+
const digest = hmac.update(body).digest('hex')
|
|
19
|
+
|
|
20
|
+
const sigBuf = Buffer.from(signature)
|
|
21
|
+
const digBuf = Buffer.from(digest)
|
|
22
|
+
if (sigBuf.length !== digBuf.length || !crypto.timingSafeEqual(sigBuf, digBuf)) {
|
|
23
|
+
return NextResponse.json({ error: 'Invalid webhook signature' }, { status: 400 })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let event: ReturnType<typeof JSON.parse>
|
|
27
|
+
try {
|
|
28
|
+
event = JSON.parse(body)
|
|
29
|
+
} catch {
|
|
30
|
+
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!event?.meta?.event_name || typeof event.meta.event_name !== 'string') {
|
|
34
|
+
return NextResponse.json({ error: 'Invalid webhook payload structure' }, { status: 400 })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const eventName: string = event.meta.event_name
|
|
38
|
+
|
|
39
|
+
switch (eventName) {
|
|
40
|
+
case 'order_created': {
|
|
41
|
+
// TODO: Record purchase in database and trigger post-purchase workflow
|
|
42
|
+
// Example: await db.orders.create({ customerId: event.data.customer_id, amount: event.data.total })
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
case 'subscription_created':
|
|
46
|
+
case 'subscription_updated':
|
|
47
|
+
case 'subscription_cancelled': {
|
|
48
|
+
// TODO: Update user subscription status in database
|
|
49
|
+
// Example: await db.users.update(subscription.customerId, { subscriptionStatus: eventName })
|
|
50
|
+
break
|
|
51
|
+
}
|
|
52
|
+
default:
|
|
53
|
+
// Silently ignore unhandled event types
|
|
54
|
+
break
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return NextResponse.json({ received: true })
|
|
58
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { lemonSqueezySetup } from '@lemonsqueezy/lemonsqueezy.js'
|
|
2
|
+
|
|
3
|
+
const apiKey = process.env.LEMONSQUEEZY_API_KEY
|
|
4
|
+
if (!apiKey) {
|
|
5
|
+
throw new Error('Missing required environment variable: LEMONSQUEEZY_API_KEY')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// lemonSqueezySetup configures the global client. Import API functions directly:
|
|
9
|
+
// import { listProducts, createCheckout } from '@lemonsqueezy/lemonsqueezy.js'
|
|
10
|
+
lemonSqueezySetup({
|
|
11
|
+
apiKey,
|
|
12
|
+
onError: (error) => console.error('Lemon Squeezy error:', error),
|
|
13
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import Stripe from 'stripe'
|
|
3
|
+
import { stripe } from '@/lib/stripe'
|
|
4
|
+
|
|
5
|
+
export async function POST(req: NextRequest) {
|
|
6
|
+
const body = await req.text()
|
|
7
|
+
const signature = req.headers.get('stripe-signature')
|
|
8
|
+
|
|
9
|
+
if (!signature) {
|
|
10
|
+
return NextResponse.json({ error: 'Missing stripe-signature header' }, { status: 400 })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET
|
|
14
|
+
if (!webhookSecret) {
|
|
15
|
+
return NextResponse.json({ error: 'Webhook secret not configured' }, { status: 500 })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let event: Stripe.Event
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
|
|
22
|
+
} catch {
|
|
23
|
+
return NextResponse.json({ error: 'Webhook signature verification failed' }, { status: 400 })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
switch (event.type) {
|
|
27
|
+
case 'checkout.session.completed': {
|
|
28
|
+
const session = event.data.object as Stripe.Checkout.Session
|
|
29
|
+
// TODO: Update user subscription status and handle successful checkout
|
|
30
|
+
// Example: await db.users.update(session.client_reference_id, { subscriptionActive: true })
|
|
31
|
+
break
|
|
32
|
+
}
|
|
33
|
+
case 'customer.subscription.updated':
|
|
34
|
+
case 'customer.subscription.deleted': {
|
|
35
|
+
const subscription = event.data.object as Stripe.Subscription
|
|
36
|
+
// TODO: Update user subscription status based on subscription state
|
|
37
|
+
// Example: await db.users.update(subscription.metadata.userId, { subscriptionActive: subscription.status === 'active' })
|
|
38
|
+
break
|
|
39
|
+
}
|
|
40
|
+
default:
|
|
41
|
+
// Silently ignore unhandled event types
|
|
42
|
+
break
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return NextResponse.json({ received: true })
|
|
46
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import Stripe from 'stripe'
|
|
2
|
+
|
|
3
|
+
const secretKey = process.env.STRIPE_SECRET_KEY
|
|
4
|
+
if (!secretKey) {
|
|
5
|
+
throw new Error('Missing required environment variable: STRIPE_SECRET_KEY')
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const stripe = new Stripe(secretKey, {
|
|
9
|
+
apiVersion: '2025-01-27.acacia',
|
|
10
|
+
})
|