create-softeneers-app 0.2.1 → 0.2.3
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.html +4 -4
- package/README.md +6 -6
- package/dist/args.js +23 -2
- package/dist/args.js.map +1 -1
- package/dist/fragments.js +1 -1
- package/dist/fragments.js.map +1 -1
- package/dist/index.js +10 -7
- package/dist/index.js.map +1 -1
- package/dist/prompts.js +12 -2
- package/dist/prompts.js.map +1 -1
- package/dist/scaffold.js +24 -1
- package/dist/scaffold.js.map +1 -1
- package/package.json +1 -1
- package/templates/express-api/.env.example +23 -0
- package/templates/express-api/README.md +68 -1
- package/templates/express-api/gitignore +7 -0
- package/templates/express-api/package.json +3 -0
- package/templates/express-api/softeneers.template.json +20 -1
- package/templates/express-api/src/email/mailer.ts +6 -0
- package/templates/express-api/src/email/routes.ts +26 -0
- package/templates/express-api/src/env.ts +18 -0
- package/templates/express-api/src/index.ts +23 -0
- package/templates/express-api/src/payments/routes.ts +44 -0
- package/templates/express-api/src/payments/stripe.ts +6 -0
- package/templates/express-api/src/payments/webhook.ts +43 -0
- package/templates/express-api/src/storage/routes.ts +28 -0
- package/templates/express-api/src/storage/store.ts +13 -0
- package/templates/hono-api/.env.example +23 -0
- package/templates/hono-api/README.md +69 -2
- package/templates/hono-api/gitignore +7 -0
- package/templates/hono-api/package.json +3 -0
- package/templates/hono-api/softeneers.template.json +20 -1
- package/templates/hono-api/src/email/mailer.ts +6 -0
- package/templates/hono-api/src/email/routes.ts +24 -0
- package/templates/hono-api/src/env.ts +18 -0
- package/templates/hono-api/src/index.ts +20 -0
- package/templates/hono-api/src/payments/routes.ts +41 -0
- package/templates/hono-api/src/payments/stripe.ts +6 -0
- package/templates/hono-api/src/payments/webhook.ts +36 -0
- package/templates/hono-api/src/storage/routes.ts +29 -0
- package/templates/hono-api/src/storage/store.ts +13 -0
- package/templates/minimal/gitignore +7 -0
- package/templates/next-fullstack/apps/web/gitignore +41 -0
- package/templates/next-fullstack/gitignore +11 -0
- package/templates/tanstack-start/.env.example +23 -0
- package/templates/tanstack-start/README.md +58 -3
- package/templates/tanstack-start/gitignore +16 -0
- package/templates/tanstack-start/package.json +4 -0
- package/templates/tanstack-start/softeneers.template.json +28 -3
- package/templates/tanstack-start/src/lib/auth-client.ts +4 -0
- package/templates/tanstack-start/src/routes/account.tsx +47 -0
- package/templates/tanstack-start/src/routes/api/webhooks/stripe.ts +43 -0
- package/templates/tanstack-start/src/routes/billing.tsx +59 -0
- package/templates/tanstack-start/src/routes/index.tsx +15 -6
- package/templates/tanstack-start/src/routes/login.tsx +77 -0
- package/templates/tanstack-start/src/server/email.ts +27 -0
- package/templates/tanstack-start/src/server/env.ts +18 -0
- package/templates/tanstack-start/src/server/payments.ts +40 -0
- package/templates/tanstack-start/src/server/storage.ts +36 -0
|
@@ -19,7 +19,11 @@
|
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@softeneers/auth": "^0.1.0",
|
|
21
21
|
"@softeneers/db": "^0.1.0",
|
|
22
|
+
"@softeneers/email": "^0.1.0",
|
|
22
23
|
"@softeneers/env": "^0.1.0",
|
|
24
|
+
"@softeneers/payments": "^0.1.0",
|
|
25
|
+
"@softeneers/storage": "^0.1.0",
|
|
26
|
+
"better-auth": "^1.6.0",
|
|
23
27
|
"@tailwindcss/vite": "^4.1.18",
|
|
24
28
|
"@tanstack/react-devtools": "latest",
|
|
25
29
|
"@tanstack/react-router": "latest",
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"toggles": {
|
|
2
|
+
"toggles": {
|
|
3
|
+
"db": false,
|
|
4
|
+
"auth": false,
|
|
5
|
+
"docker": false,
|
|
6
|
+
"email": false,
|
|
7
|
+
"storage": false,
|
|
8
|
+
"payments": false
|
|
9
|
+
},
|
|
3
10
|
"fragments": {
|
|
4
11
|
"db": {
|
|
5
12
|
"removePaths": ["src/server/db.ts", "src/server/scripts", "docker-compose.yml"],
|
|
@@ -7,11 +14,29 @@
|
|
|
7
14
|
"removeScripts": ["db:migrate", "db:seed", "db:reset"]
|
|
8
15
|
},
|
|
9
16
|
"auth": {
|
|
10
|
-
"removePaths": [
|
|
11
|
-
|
|
17
|
+
"removePaths": [
|
|
18
|
+
"src/server/auth.ts",
|
|
19
|
+
"src/routes/api/auth",
|
|
20
|
+
"src/lib/auth-client.ts",
|
|
21
|
+
"src/routes/login.tsx",
|
|
22
|
+
"src/routes/account.tsx"
|
|
23
|
+
],
|
|
24
|
+
"removeDeps": ["@softeneers/auth", "better-auth"]
|
|
12
25
|
},
|
|
13
26
|
"docker": {
|
|
14
27
|
"removePaths": ["docker-compose.yml"]
|
|
28
|
+
},
|
|
29
|
+
"email": {
|
|
30
|
+
"removePaths": ["src/server/email.ts"],
|
|
31
|
+
"removeDeps": ["@softeneers/email"]
|
|
32
|
+
},
|
|
33
|
+
"storage": {
|
|
34
|
+
"removePaths": ["src/server/storage.ts"],
|
|
35
|
+
"removeDeps": ["@softeneers/storage"]
|
|
36
|
+
},
|
|
37
|
+
"payments": {
|
|
38
|
+
"removePaths": ["src/server/payments.ts", "src/routes/api/webhooks", "src/routes/billing.tsx"],
|
|
39
|
+
"removeDeps": ["@softeneers/payments"]
|
|
15
40
|
}
|
|
16
41
|
}
|
|
17
42
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Link, createFileRoute, useRouter } from '@tanstack/react-router'
|
|
2
|
+
|
|
3
|
+
import { authClient } from '../lib/auth-client'
|
|
4
|
+
|
|
5
|
+
export const Route = createFileRoute('/account')({ component: Account })
|
|
6
|
+
|
|
7
|
+
function Account() {
|
|
8
|
+
const router = useRouter()
|
|
9
|
+
const { data: session, isPending } = authClient.useSession()
|
|
10
|
+
|
|
11
|
+
if (isPending) {
|
|
12
|
+
return <div className="mx-auto max-w-sm p-8 text-gray-500">Loading…</div>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (!session) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="mx-auto max-w-sm p-8">
|
|
18
|
+
<p>You're not signed in.</p>
|
|
19
|
+
<Link to="/login" className="mt-3 inline-block text-blue-600 hover:underline">
|
|
20
|
+
Go to sign in →
|
|
21
|
+
</Link>
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function signOut() {
|
|
27
|
+
await authClient.signOut()
|
|
28
|
+
router.navigate({ to: '/login' })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="mx-auto max-w-sm p-8">
|
|
33
|
+
<h1 className="text-3xl font-bold">Account</h1>
|
|
34
|
+
<p className="mt-3">
|
|
35
|
+
Signed in as <strong>{session.user.email}</strong>
|
|
36
|
+
{session.user.name ? ` (${session.user.name})` : ''}.
|
|
37
|
+
</p>
|
|
38
|
+
<button
|
|
39
|
+
className="mt-6 rounded border border-gray-300 px-4 py-2 font-medium"
|
|
40
|
+
type="button"
|
|
41
|
+
onClick={signOut}
|
|
42
|
+
>
|
|
43
|
+
Sign out
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
|
|
3
|
+
import { constructWebhookEvent, createStripe } from '@softeneers/payments'
|
|
4
|
+
|
|
5
|
+
import { env } from '../../../server/env'
|
|
6
|
+
|
|
7
|
+
const stripe = createStripe(env.STRIPE_SECRET_KEY)
|
|
8
|
+
|
|
9
|
+
// Catch-all Stripe webhook server route. Reads the raw body for signature
|
|
10
|
+
// verification and handles both payment and subscription events.
|
|
11
|
+
export const Route = createFileRoute('/api/webhooks/stripe')({
|
|
12
|
+
server: {
|
|
13
|
+
handlers: {
|
|
14
|
+
POST: async ({ request }) => {
|
|
15
|
+
const signature = request.headers.get('stripe-signature')
|
|
16
|
+
if (!signature) return new Response('Missing stripe-signature header.', { status: 400 })
|
|
17
|
+
|
|
18
|
+
let event
|
|
19
|
+
try {
|
|
20
|
+
event = constructWebhookEvent(stripe, await request.text(), signature, env.STRIPE_WEBHOOK_SECRET)
|
|
21
|
+
} catch (error) {
|
|
22
|
+
console.error('Stripe webhook signature verification failed:', error)
|
|
23
|
+
return new Response('Invalid signature.', { status: 400 })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
switch (event.type) {
|
|
27
|
+
case 'checkout.session.completed':
|
|
28
|
+
console.log('✓ checkout.session.completed', event.data.object.id)
|
|
29
|
+
break
|
|
30
|
+
case 'customer.subscription.created':
|
|
31
|
+
case 'customer.subscription.updated':
|
|
32
|
+
case 'customer.subscription.deleted':
|
|
33
|
+
console.log(`✓ ${event.type}`, event.data.object.id)
|
|
34
|
+
break
|
|
35
|
+
default:
|
|
36
|
+
console.log('Unhandled Stripe event:', event.type)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return Response.json({ received: true })
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { startCheckout, startSubscription } from '../server/payments'
|
|
5
|
+
|
|
6
|
+
export const Route = createFileRoute('/billing')({ component: Billing })
|
|
7
|
+
|
|
8
|
+
function Billing() {
|
|
9
|
+
const [status, setStatus] = useState({ paid: false, subscribed: false, canceled: false })
|
|
10
|
+
|
|
11
|
+
// Read the Stripe redirect result on the client (avoids a hydration mismatch).
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const p = new URLSearchParams(window.location.search)
|
|
14
|
+
setStatus({
|
|
15
|
+
paid: p.get('paid') === '1',
|
|
16
|
+
subscribed: p.get('subscribed') === '1',
|
|
17
|
+
canceled: p.get('canceled') === '1',
|
|
18
|
+
})
|
|
19
|
+
}, [])
|
|
20
|
+
|
|
21
|
+
async function buy() {
|
|
22
|
+
const { url } = await startCheckout()
|
|
23
|
+
if (url) window.location.href = url
|
|
24
|
+
}
|
|
25
|
+
async function subscribe() {
|
|
26
|
+
const { url } = await startSubscription()
|
|
27
|
+
if (url) window.location.href = url
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="mx-auto max-w-xl p-8">
|
|
32
|
+
<h1 className="text-3xl font-bold">Billing</h1>
|
|
33
|
+
<p className="mt-1 text-gray-500">Stripe checkout — add your keys to .env and this just works.</p>
|
|
34
|
+
|
|
35
|
+
{status.paid && (
|
|
36
|
+
<p className="mt-4 rounded bg-green-50 p-3 text-green-700">Payment successful — thank you!</p>
|
|
37
|
+
)}
|
|
38
|
+
{status.subscribed && (
|
|
39
|
+
<p className="mt-4 rounded bg-green-50 p-3 text-green-700">You're subscribed — thank you!</p>
|
|
40
|
+
)}
|
|
41
|
+
{status.canceled && (
|
|
42
|
+
<p className="mt-4 rounded bg-yellow-50 p-3 text-yellow-700">Checkout canceled.</p>
|
|
43
|
+
)}
|
|
44
|
+
|
|
45
|
+
<div className="mt-6 flex gap-3">
|
|
46
|
+
<button className="rounded bg-black px-5 py-2.5 font-medium text-white" type="button" onClick={buy}>
|
|
47
|
+
Buy once
|
|
48
|
+
</button>
|
|
49
|
+
<button
|
|
50
|
+
className="rounded border border-gray-300 px-5 py-2.5 font-medium"
|
|
51
|
+
type="button"
|
|
52
|
+
onClick={subscribe}
|
|
53
|
+
>
|
|
54
|
+
Subscribe
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
)
|
|
59
|
+
}
|
|
@@ -9,12 +9,21 @@ function Home() {
|
|
|
9
9
|
<p className="mt-4 text-lg text-gray-600">
|
|
10
10
|
Generated by create-softeneers-app. A fullstack React app with type-safe server functions.
|
|
11
11
|
</p>
|
|
12
|
-
<
|
|
13
|
-
to="/cars"
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
<div className="mt-6 flex flex-wrap gap-3">
|
|
13
|
+
<Link to="/cars" className="rounded bg-black px-5 py-2.5 font-medium text-white">
|
|
14
|
+
Cars CRUD demo →
|
|
15
|
+
</Link>
|
|
16
|
+
{/* #if auth */}
|
|
17
|
+
<Link to="/login" className="rounded border border-gray-300 px-5 py-2.5 font-medium">
|
|
18
|
+
Sign in
|
|
19
|
+
</Link>
|
|
20
|
+
{/* #endif */}
|
|
21
|
+
{/* #if payments */}
|
|
22
|
+
<Link to="/billing" className="rounded border border-gray-300 px-5 py-2.5 font-medium">
|
|
23
|
+
Billing
|
|
24
|
+
</Link>
|
|
25
|
+
{/* #endif */}
|
|
26
|
+
</div>
|
|
18
27
|
</div>
|
|
19
28
|
)
|
|
20
29
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { createFileRoute, useRouter } from '@tanstack/react-router'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
|
|
4
|
+
import { authClient } from '../lib/auth-client'
|
|
5
|
+
|
|
6
|
+
export const Route = createFileRoute('/login')({ component: Login })
|
|
7
|
+
|
|
8
|
+
function Login() {
|
|
9
|
+
const router = useRouter()
|
|
10
|
+
const [mode, setMode] = useState<'in' | 'up'>('in')
|
|
11
|
+
const [name, setName] = useState('')
|
|
12
|
+
const [email, setEmail] = useState('')
|
|
13
|
+
const [password, setPassword] = useState('')
|
|
14
|
+
const [error, setError] = useState('')
|
|
15
|
+
const [busy, setBusy] = useState(false)
|
|
16
|
+
|
|
17
|
+
async function submit(event: React.FormEvent) {
|
|
18
|
+
event.preventDefault()
|
|
19
|
+
setError('')
|
|
20
|
+
setBusy(true)
|
|
21
|
+
const res =
|
|
22
|
+
mode === 'in'
|
|
23
|
+
? await authClient.signIn.email({ email, password })
|
|
24
|
+
: await authClient.signUp.email({ email, password, name })
|
|
25
|
+
setBusy(false)
|
|
26
|
+
if (res.error) {
|
|
27
|
+
setError(res.error.message ?? 'Something went wrong.')
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
router.navigate({ to: '/account' })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="mx-auto max-w-sm p-8">
|
|
35
|
+
<h1 className="text-3xl font-bold">{mode === 'in' ? 'Sign in' : 'Create account'}</h1>
|
|
36
|
+
<form onSubmit={submit} className="mt-6 space-y-3">
|
|
37
|
+
{mode === 'up' && (
|
|
38
|
+
<input
|
|
39
|
+
className="w-full rounded border border-gray-300 px-3 py-2"
|
|
40
|
+
placeholder="Name"
|
|
41
|
+
value={name}
|
|
42
|
+
onChange={(e) => setName(e.target.value)}
|
|
43
|
+
/>
|
|
44
|
+
)}
|
|
45
|
+
<input
|
|
46
|
+
className="w-full rounded border border-gray-300 px-3 py-2"
|
|
47
|
+
type="email"
|
|
48
|
+
placeholder="Email"
|
|
49
|
+
value={email}
|
|
50
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
51
|
+
/>
|
|
52
|
+
<input
|
|
53
|
+
className="w-full rounded border border-gray-300 px-3 py-2"
|
|
54
|
+
type="password"
|
|
55
|
+
placeholder="Password (min 8 chars)"
|
|
56
|
+
value={password}
|
|
57
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
58
|
+
/>
|
|
59
|
+
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
60
|
+
<button
|
|
61
|
+
className="w-full rounded bg-black px-4 py-2 font-medium text-white disabled:opacity-50"
|
|
62
|
+
type="submit"
|
|
63
|
+
disabled={busy}
|
|
64
|
+
>
|
|
65
|
+
{mode === 'in' ? 'Sign in' : 'Create account'}
|
|
66
|
+
</button>
|
|
67
|
+
</form>
|
|
68
|
+
<button
|
|
69
|
+
className="mt-4 text-sm text-gray-500 hover:underline"
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={() => setMode(mode === 'in' ? 'up' : 'in')}
|
|
72
|
+
>
|
|
73
|
+
{mode === 'in' ? 'Need an account? Sign up' : 'Have an account? Sign in'}
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
2
|
+
|
|
3
|
+
import { createEmailClient, sendEmail } from '@softeneers/email'
|
|
4
|
+
|
|
5
|
+
import { env } from './env'
|
|
6
|
+
|
|
7
|
+
const mailer = createEmailClient(env.RESEND_API_KEY)
|
|
8
|
+
|
|
9
|
+
// Server function: send a welcome email. Call from the client as
|
|
10
|
+
// `await sendWelcomeEmail({ data: { to, name } })`.
|
|
11
|
+
export const sendWelcomeEmail = createServerFn({ method: 'POST' })
|
|
12
|
+
.validator((data: unknown) => {
|
|
13
|
+
const d = (data ?? {}) as Record<string, unknown>
|
|
14
|
+
const to = typeof d.to === 'string' ? d.to : ''
|
|
15
|
+
if (!to) throw new Error('to (email address) is required.')
|
|
16
|
+
return { to, name: typeof d.name === 'string' ? d.name : 'there' }
|
|
17
|
+
})
|
|
18
|
+
.handler(async ({ data }) => {
|
|
19
|
+
await sendEmail(mailer, {
|
|
20
|
+
from: env.EMAIL_FROM,
|
|
21
|
+
to: data.to,
|
|
22
|
+
subject: 'Welcome!',
|
|
23
|
+
html: `<h1>Welcome, ${data.name}!</h1><p>Thanks for joining.</p>`,
|
|
24
|
+
text: `Welcome, ${data.name}! Thanks for joining.`,
|
|
25
|
+
})
|
|
26
|
+
return { sent: true }
|
|
27
|
+
})
|
|
@@ -18,5 +18,23 @@ export const env = createEnv({
|
|
|
18
18
|
AUTH_SECRET: z.string().min(16).default('dev-secret-change-me-to-a-long-random-string'),
|
|
19
19
|
AUTH_BASE_URL: z.string().default('http://localhost:3000'),
|
|
20
20
|
// #endif
|
|
21
|
+
// #if email
|
|
22
|
+
RESEND_API_KEY: z.string().default('re_set_me_in_dotenv'),
|
|
23
|
+
EMAIL_FROM: z.string().default('onboarding@resend.dev'),
|
|
24
|
+
// #endif
|
|
25
|
+
// #if storage
|
|
26
|
+
S3_ACCESS_KEY_ID: z.string().default(''),
|
|
27
|
+
S3_SECRET_ACCESS_KEY: z.string().default(''),
|
|
28
|
+
S3_BUCKET: z.string().default('uploads'),
|
|
29
|
+
S3_REGION: z.string().default('auto'),
|
|
30
|
+
S3_ENDPOINT: z.string().default(''),
|
|
31
|
+
// #endif
|
|
32
|
+
// #if payments
|
|
33
|
+
APP_URL: z.string().default('http://localhost:3000'),
|
|
34
|
+
STRIPE_SECRET_KEY: z.string().default('sk_test_set_me_in_dotenv'),
|
|
35
|
+
STRIPE_WEBHOOK_SECRET: z.string().default('whsec_set_me_in_dotenv'),
|
|
36
|
+
STRIPE_PRICE_ID: z.string().default('price_set_me_in_dotenv'),
|
|
37
|
+
STRIPE_SUBSCRIPTION_PRICE_ID: z.string().default('price_set_me_in_dotenv'),
|
|
38
|
+
// #endif
|
|
21
39
|
},
|
|
22
40
|
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
2
|
+
|
|
3
|
+
import { createBillingPortalSession, createCheckoutSession, createStripe } from '@softeneers/payments'
|
|
4
|
+
|
|
5
|
+
import { env } from './env'
|
|
6
|
+
|
|
7
|
+
const stripe = createStripe(env.STRIPE_SECRET_KEY)
|
|
8
|
+
|
|
9
|
+
// Server functions: the browser calls these directly; the Stripe secret key
|
|
10
|
+
// never leaves the server. Each returns a URL to redirect the browser to.
|
|
11
|
+
export const startCheckout = createServerFn({ method: 'POST' }).handler(async () => {
|
|
12
|
+
const session = await createCheckoutSession(stripe, {
|
|
13
|
+
mode: 'payment',
|
|
14
|
+
priceId: env.STRIPE_PRICE_ID,
|
|
15
|
+
successUrl: `${env.APP_URL}/billing?paid=1`,
|
|
16
|
+
cancelUrl: `${env.APP_URL}/billing?canceled=1`,
|
|
17
|
+
})
|
|
18
|
+
return { url: session.url }
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export const startSubscription = createServerFn({ method: 'POST' }).handler(async () => {
|
|
22
|
+
const session = await createCheckoutSession(stripe, {
|
|
23
|
+
mode: 'subscription',
|
|
24
|
+
priceId: env.STRIPE_SUBSCRIPTION_PRICE_ID,
|
|
25
|
+
successUrl: `${env.APP_URL}/billing?subscribed=1`,
|
|
26
|
+
cancelUrl: `${env.APP_URL}/billing?canceled=1`,
|
|
27
|
+
})
|
|
28
|
+
return { url: session.url }
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
export const openBillingPortal = createServerFn({ method: 'POST' })
|
|
32
|
+
.validator((customer: unknown) => String(customer ?? ''))
|
|
33
|
+
.handler(async ({ data: customer }) => {
|
|
34
|
+
if (!customer) throw new Error('A Stripe customer id (cus_…) is required.')
|
|
35
|
+
const session = await createBillingPortalSession(stripe, {
|
|
36
|
+
customer,
|
|
37
|
+
returnUrl: `${env.APP_URL}/billing`,
|
|
38
|
+
})
|
|
39
|
+
return { url: session.url }
|
|
40
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { createServerFn } from '@tanstack/react-start'
|
|
2
|
+
|
|
3
|
+
import { createStorage, deleteFile, getSignedDownloadUrl, uploadFile } from '@softeneers/storage'
|
|
4
|
+
|
|
5
|
+
import { env } from './env'
|
|
6
|
+
|
|
7
|
+
const storage = createStorage({
|
|
8
|
+
accessKeyId: env.S3_ACCESS_KEY_ID,
|
|
9
|
+
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
|
|
10
|
+
bucket: env.S3_BUCKET,
|
|
11
|
+
region: env.S3_REGION,
|
|
12
|
+
endpoint: env.S3_ENDPOINT || undefined,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
// Server functions for S3-compatible storage. uploadText is a simple demo;
|
|
16
|
+
// for binary uploads, accept a base64 string or use a presigned upload URL.
|
|
17
|
+
export const uploadText = createServerFn({ method: 'POST' })
|
|
18
|
+
.validator((data: unknown) => {
|
|
19
|
+
const d = (data ?? {}) as Record<string, unknown>
|
|
20
|
+
return { key: String(d.key ?? 'demo.txt'), content: String(d.content ?? '') }
|
|
21
|
+
})
|
|
22
|
+
.handler(async ({ data }) => {
|
|
23
|
+
await uploadFile(storage, { key: data.key, body: data.content, contentType: 'text/plain' })
|
|
24
|
+
return { key: data.key }
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
export const getFileUrl = createServerFn({ method: 'GET' })
|
|
28
|
+
.validator((key: unknown) => String(key))
|
|
29
|
+
.handler(async ({ data: key }) => ({ url: await getSignedDownloadUrl(storage, key) }))
|
|
30
|
+
|
|
31
|
+
export const removeFile = createServerFn({ method: 'POST' })
|
|
32
|
+
.validator((key: unknown) => String(key))
|
|
33
|
+
.handler(async ({ data: key }) => {
|
|
34
|
+
await deleteFile(storage, key)
|
|
35
|
+
return { deleted: true }
|
|
36
|
+
})
|