create-softeneers-app 0.2.2 → 0.2.4

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.
Files changed (55) hide show
  1. package/README.html +4 -4
  2. package/README.md +6 -6
  3. package/dist/args.js +23 -2
  4. package/dist/args.js.map +1 -1
  5. package/dist/fragments.js +1 -1
  6. package/dist/fragments.js.map +1 -1
  7. package/dist/index.js +10 -7
  8. package/dist/index.js.map +1 -1
  9. package/dist/prompts.js +12 -2
  10. package/dist/prompts.js.map +1 -1
  11. package/package.json +1 -1
  12. package/templates/express-api/.env.example +23 -0
  13. package/templates/express-api/README.md +68 -1
  14. package/templates/express-api/package.json +3 -0
  15. package/templates/express-api/softeneers.template.json +20 -1
  16. package/templates/express-api/src/email/mailer.ts +6 -0
  17. package/templates/express-api/src/email/routes.ts +26 -0
  18. package/templates/express-api/src/env.ts +18 -0
  19. package/templates/express-api/src/index.ts +23 -0
  20. package/templates/express-api/src/payments/routes.ts +44 -0
  21. package/templates/express-api/src/payments/stripe.ts +6 -0
  22. package/templates/express-api/src/payments/webhook.ts +43 -0
  23. package/templates/express-api/src/storage/routes.ts +28 -0
  24. package/templates/express-api/src/storage/store.ts +13 -0
  25. package/templates/hono-api/.env.example +23 -0
  26. package/templates/hono-api/README.md +69 -2
  27. package/templates/hono-api/package.json +3 -0
  28. package/templates/hono-api/softeneers.template.json +20 -1
  29. package/templates/hono-api/src/email/mailer.ts +6 -0
  30. package/templates/hono-api/src/email/routes.ts +24 -0
  31. package/templates/hono-api/src/env.ts +18 -0
  32. package/templates/hono-api/src/index.ts +20 -0
  33. package/templates/hono-api/src/payments/routes.ts +41 -0
  34. package/templates/hono-api/src/payments/stripe.ts +6 -0
  35. package/templates/hono-api/src/payments/webhook.ts +36 -0
  36. package/templates/hono-api/src/storage/routes.ts +29 -0
  37. package/templates/hono-api/src/storage/store.ts +13 -0
  38. package/templates/next-fullstack/apps/server/package.json +1 -0
  39. package/templates/next-fullstack/apps/web/package.json +1 -0
  40. package/templates/next-fullstack/package.json +1 -0
  41. package/templates/next-fullstack/turbo.json +3 -0
  42. package/templates/tanstack-start/.env.example +23 -0
  43. package/templates/tanstack-start/README.md +58 -3
  44. package/templates/tanstack-start/package.json +4 -0
  45. package/templates/tanstack-start/softeneers.template.json +28 -3
  46. package/templates/tanstack-start/src/lib/auth-client.ts +4 -0
  47. package/templates/tanstack-start/src/routes/account.tsx +47 -0
  48. package/templates/tanstack-start/src/routes/api/webhooks/stripe.ts +43 -0
  49. package/templates/tanstack-start/src/routes/billing.tsx +59 -0
  50. package/templates/tanstack-start/src/routes/index.tsx +15 -6
  51. package/templates/tanstack-start/src/routes/login.tsx +77 -0
  52. package/templates/tanstack-start/src/server/email.ts +27 -0
  53. package/templates/tanstack-start/src/server/env.ts +18 -0
  54. package/templates/tanstack-start/src/server/payments.ts +40 -0
  55. package/templates/tanstack-start/src/server/storage.ts +36 -0
@@ -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
- <Link
13
- to="/cars"
14
- className="mt-6 inline-block rounded bg-black px-5 py-2.5 font-medium text-white"
15
- >
16
- Open the cars CRUD demo →
17
- </Link>
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
+ })