create-lacspace-app 2.6.0 → 2.8.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 CHANGED
@@ -20,6 +20,17 @@ npx create-lacspace-app my-app --template saas --fullstack
20
20
 
21
21
  You choose the *kind* of site you're building. It writes a **real Next.js 15 + React 19 + Tailwind v4 app** — not a hello-world, but a genuinely **polished, modern site**: a fluid `clamp()` type scale, tight display headings, a refined light **and** dark palette, glass chrome, soft layered shadows, a smooth logo marquee, animated counters, scroll reveals and a shimmering primary CTA — every page filled in, an SEO stack wired end-to-end, and a **26-component UI kit** you can drop in anywhere.
22
22
 
23
+ > **New in v2.8 — recipes.** Scaffold a whole *kind of product* in one command with `--recipe <key>` (a curated template + full-stack mode + add-on stack):
24
+ > - **`ai-saas`** → SaaS + accounts + payments + AI chat + analytics
25
+ > - **`store`** → e-commerce + eSewa/Khalti checkout + email + analytics
26
+ > - **`blog`** → blog + content + search · **`docs-ai`** → docs + RAG + search · **`internal-tool`** → dashboard + auth + analytics + email
27
+ >
28
+ > `npx create-lacspace-app my-app --recipe ai-saas`. Explicit `--template`/`--with`/`--fullstack` still merge on top. Also on the lib API: `listRecipes()` / `getRecipe()`.
29
+
30
+ > **New in v2.7 — payments & email add-ons.**
31
+ > - **`payments`** — a checkout wired to **eSewa & Khalti**: orders, integer-safe money (`@lacspace/money`), and the signed eSewa flow that **works end-to-end in test mode with NO credentials**. Khalti activates when you add `KHALTI_SECRET`.
32
+ > - **`email`** — transactional email: a ready mail service (`@lacspace/mailer`) with beautiful templates (`@lacspace/email-templates`) + address validation. **Logs emails to the console until you add SMTP** — so it runs out-of-the-box, then delivers for real with one env change.
33
+
23
34
  > **New in v2.6 — full-stack add-ons.** Add-ons can now wire the **backend** too. Requesting one automatically upgrades your project to full-stack (`--fullstack`):
24
35
  > - **`auth-pages`** — account management on top of the built-in login/register: edit profile, change password, and **TOTP two-factor auth (2FA)** with backup codes (`@lacspace/otp`), plus a settings page.
25
36
  > - **`analytics`** — **privacy-first, cookieless** web analytics (`@lacspace/analytics-lite`): a tracker, a MongoDB collector, and a dashboard. No cookies, no personal data.
package/dist/index.js CHANGED
@@ -29,14 +29,15 @@ var TEMPLATES = [
29
29
  { key: "marketplace", label: "Marketplace / commerce", description: "A real storefront wired to the Lacspace commerce packages \u2014 cart, checkout, tax, shipping, orders, invoices and Nepal payments.", accent: ["#0d9488", "#6366f1"], siteName: "LSBazaar", siteDescription: "A modern storefront \u2014 cart to checkout, wired end to end." }
30
30
  ];
31
31
  function resolveContext(options = {}) {
32
- const base = TEMPLATES.find((t) => t.key === options.template) ?? TEMPLATES[0];
32
+ const recipe = options.recipe ? RECIPES.find((r) => r.key === String(options.recipe).toLowerCase()) : void 0;
33
+ const base = TEMPLATES.find((t) => t.key === (options.template ?? recipe?.template)) ?? TEMPLATES[0];
33
34
  const accent = resolveAccent(options.theme);
34
35
  const template = accent ? { ...base, accent } : base;
35
36
  const raw = options.name ?? "my-app";
36
37
  const seg = raw.split(/[\\/]/).filter(Boolean).pop() ?? "my-app";
37
38
  const name = seg.toLowerCase().replace(/[^a-z0-9-_]/g, "-").replace(/^-+|-+$/g, "") || "my-app";
38
- const features = normalizeFeatures(options.features);
39
- let mode = options.mode === "dynamic" ? "dynamic" : "static";
39
+ const features = normalizeFeatures([...recipe?.features ?? [], ...options.features ?? []]);
40
+ let mode = options.mode === "dynamic" ? "dynamic" : options.mode === "static" ? "static" : recipe?.mode ?? "static";
40
41
  if (mode === "static" && features.some((f) => f.requiresBackend)) mode = "dynamic";
41
42
  return { name, template, features, mode };
42
43
  }
@@ -2209,6 +2210,71 @@ var FEATURES = [
2209
2210
  "It's cookieless and stores no personal data \u2014 privacy-first by default."
2210
2211
  ],
2211
2212
  learn: "https://developer.lacspace.com/packages/analytics-lite"
2213
+ },
2214
+ {
2215
+ key: "payments",
2216
+ label: "Payments (Nepal)",
2217
+ description: "A checkout wired to eSewa & Khalti \u2014 orders, the signed eSewa flow (works in TEST with NO credentials), Khalti when keyed, integer-safe money. Full-stack.",
2218
+ requiresBackend: true,
2219
+ deps: {},
2220
+ files: () => ({
2221
+ "app/checkout/page.tsx": checkoutPage(),
2222
+ "app/checkout/success/page.tsx": checkoutSuccessPage(),
2223
+ "app/checkout/failed/page.tsx": checkoutFailedPage()
2224
+ }),
2225
+ backend: () => ({
2226
+ deps: { "@lacspace/esewa": "^1.2.0", "@lacspace/khalti": "^1.1.0", "@lacspace/money": "^1.1.0" },
2227
+ files: {
2228
+ "src/models/order.ts": ordersModel(),
2229
+ "src/routes/checkout.ts": checkoutRoutes()
2230
+ },
2231
+ routes: [{ path: "/checkout", handler: "checkoutRoutes", auth: true, importLine: 'import checkoutRoutes from "./checkout.js";' }],
2232
+ env: {
2233
+ ESEWA_MERCHANT_CODE: "eSewa merchant/product code (blank = eSewa TEST sandbox).",
2234
+ ESEWA_SECRET: "eSewa secret key (blank = TEST sandbox \u2014 payments work end-to-end in test).",
2235
+ KHALTI_SECRET: "Khalti secret key (required to enable Khalti; blank shows a 'configure Khalti' message)."
2236
+ }
2237
+ }),
2238
+ nextSteps: [
2239
+ "Sign in, then open http://localhost:3000/checkout and pay with eSewa \u2014 it works in TEST mode with no credentials.",
2240
+ "Enable Khalti by setting KHALTI_SECRET in .env (get a test key from https://khalti.com).",
2241
+ "Go live: set ESEWA_MERCHANT_CODE + ESEWA_SECRET (and a live KHALTI_SECRET)."
2242
+ ],
2243
+ learn: "https://developer.lacspace.com/packages/esewa"
2244
+ },
2245
+ {
2246
+ key: "email",
2247
+ label: "Email",
2248
+ description: "Transactional email \u2014 a ready mail service (@lacspace/mailer) with beautiful templates + address validation. Logs to the console until you add SMTP. Full-stack.",
2249
+ requiresBackend: true,
2250
+ deps: {},
2251
+ files: () => ({ "app/email-test/page.tsx": emailTestPage() }),
2252
+ backend: (ctx) => ({
2253
+ deps: {
2254
+ "@lacspace/mailer": "^1.2.0",
2255
+ "@lacspace/email-templates": "^1.1.0",
2256
+ "@lacspace/email-validate": "^1.1.0"
2257
+ },
2258
+ files: {
2259
+ "src/mail/mailer.ts": mailerLib(ctx),
2260
+ "src/routes/email.ts": emailRoutes()
2261
+ },
2262
+ routes: [{ path: "/email", handler: "emailRoutes", auth: true, importLine: 'import emailRoutes from "./email.js";' }],
2263
+ env: {
2264
+ SMTP_HOST: "SMTP host (blank in dev \u2014 emails are logged to the API console instead of sent).",
2265
+ SMTP_PORT: "SMTP port (default 587).",
2266
+ SMTP_SECURE: "true for implicit TLS on port 465; otherwise false.",
2267
+ SMTP_USER: "SMTP username.",
2268
+ SMTP_PASS: "SMTP password.",
2269
+ SMTP_FROM: 'Default From address, e.g. "Acme <no-reply@acme.com>".'
2270
+ }
2271
+ }),
2272
+ nextSteps: [
2273
+ "Sign in, then open http://localhost:3000/email-test and send yourself a sample email.",
2274
+ "With no SMTP_* set, the email is printed to the API console (dev). Set SMTP_* in .env to send for real.",
2275
+ "Reuse the helpers in backend/src/mail/mailer.ts: sendWelcome / sendVerify / sendReset."
2276
+ ],
2277
+ learn: "https://developer.lacspace.com/packages/mailer"
2212
2278
  }
2213
2279
  ];
2214
2280
  var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
@@ -5187,6 +5253,13 @@ var faqSection = (ctx) => {
5187
5253
  </div>
5188
5254
  </section>`;
5189
5255
  };
5256
+ var RECIPES = [
5257
+ { key: "ai-saas", label: "AI SaaS", description: "SaaS landing + accounts + payments + a streaming AI chat + analytics.", template: "saas", mode: "dynamic", features: ["auth-pages", "payments", "ai-chat", "analytics"] },
5258
+ { key: "store", label: "Online store", description: "E-commerce storefront + eSewa/Khalti checkout + transactional email + analytics.", template: "ecommerce", mode: "dynamic", features: ["payments", "email", "analytics"] },
5259
+ { key: "blog", label: "Blog", description: "A blog with a Markdown content section, RSS/llms.txt and instant search.", template: "blog", features: ["content", "search"] },
5260
+ { key: "internal-tool", label: "Internal tool", description: "An admin dashboard with accounts (2FA), analytics and email \u2014 full-stack.", template: "dashboard", mode: "dynamic", features: ["auth-pages", "analytics", "email"] },
5261
+ { key: "docs-ai", label: "AI docs", description: "A documentation site with chat-with-your-docs RAG and instant search.", template: "docs", features: ["rag", "search"] }
5262
+ ];
5190
5263
  var contentLib = () => `import fs from "node:fs";
5191
5264
  import path from "node:path";
5192
5265
  import { parseFrontmatter, markdownToHtml, excerpt } from "@lacspace/markdown";
@@ -5827,6 +5900,365 @@ export default function AnalyticsPage() {
5827
5900
  );
5828
5901
  }
5829
5902
  `;
5903
+ var ordersModel = () => `import mongoose from "mongoose";
5904
+ import { uuidv7 } from "@lacspace/id";
5905
+
5906
+ export interface OrderDoc {
5907
+ _id: string;
5908
+ userId: string;
5909
+ label: string;
5910
+ amountPaisa: number;
5911
+ currency: string;
5912
+ status: "pending" | "paid" | "failed";
5913
+ gateway: string;
5914
+ ref: string;
5915
+ createdAt: Date;
5916
+ updatedAt: Date;
5917
+ }
5918
+
5919
+ const schema = new mongoose.Schema<OrderDoc>(
5920
+ {
5921
+ _id: { type: String, default: () => uuidv7() },
5922
+ userId: { type: String, required: true, index: true },
5923
+ label: { type: String, required: true },
5924
+ amountPaisa: { type: Number, required: true },
5925
+ currency: { type: String, default: "NPR" },
5926
+ status: { type: String, enum: ["pending", "paid", "failed"], default: "pending" },
5927
+ gateway: { type: String, default: "" },
5928
+ ref: { type: String, default: "" },
5929
+ },
5930
+ { timestamps: true },
5931
+ );
5932
+
5933
+ export const Order =
5934
+ (mongoose.models.Order as mongoose.Model<OrderDoc>) ?? mongoose.model<OrderDoc>("Order", schema);
5935
+ `;
5936
+ var checkoutRoutes = () => `import express from "express";
5937
+ import {
5938
+ buildForm, verifyResponse, paisaToRupees, generateTransactionUuid,
5939
+ ESEWA_TEST_SECRET, ESEWA_TEST_PRODUCT_CODE,
5940
+ } from "@lacspace/esewa";
5941
+ import { initiate, lookup } from "@lacspace/khalti";
5942
+ import { Money, formatBasic } from "@lacspace/money";
5943
+ import { v } from "@lacspace/validate";
5944
+ import { asyncHandler, HttpError } from "../http.js";
5945
+ import { env } from "../env.js";
5946
+ import { Order } from "../models/order.js";
5947
+
5948
+ // how this works: create an order, then start a gateway payment. eSewa works in TEST
5949
+ // mode with the package's baked-in sandbox keys (no credentials!). Khalti needs a real
5950
+ // secret. After the customer returns, the frontend calls /verify \u2014 never trust the
5951
+ // redirect alone, always confirm server-side.
5952
+ const router = express.Router();
5953
+ const web = () => env.CORS_ORIGIN.split(",")[0]!.trim();
5954
+
5955
+ const CreateInput = v.object({ label: v.string().min(1).max(120), amount: v.number().positive() });
5956
+
5957
+ // POST /checkout \u2014 create a pending order (amount in RUPEES from the UI \u2192 stored as paisa).
5958
+ router.post("/", asyncHandler(async (req, res) => {
5959
+ const { label, amount } = CreateInput.parse(req.body);
5960
+ const amountPaisa = Money.of(amount, "NPR").toMinor();
5961
+ const order = await Order.create({ userId: req.user!.sub, label, amountPaisa });
5962
+ res.status(201).json({ id: String(order._id), label, amountPaisa, display: formatBasic(Money.fromMinor(amountPaisa, "NPR")) });
5963
+ }));
5964
+
5965
+ // POST /checkout/:id/esewa \u2014 build the signed eSewa form to auto-POST from the browser.
5966
+ router.post("/:id/esewa", asyncHandler(async (req, res) => {
5967
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
5968
+ if (!order) throw new HttpError(404, "Order not found");
5969
+ const secret = process.env.ESEWA_SECRET ?? ESEWA_TEST_SECRET;
5970
+ const productCode = process.env.ESEWA_MERCHANT_CODE ?? ESEWA_TEST_PRODUCT_CODE;
5971
+ const esewaEnv: "test" | "prod" = process.env.ESEWA_SECRET ? "prod" : "test";
5972
+ const uuid = generateTransactionUuid();
5973
+ order.ref = uuid;
5974
+ order.gateway = "esewa";
5975
+ await order.save();
5976
+ const form = await buildForm(
5977
+ {
5978
+ amount: paisaToRupees(order.amountPaisa),
5979
+ transactionUuid: uuid,
5980
+ productCode,
5981
+ successUrl: web() + "/checkout/success?orderId=" + String(order._id),
5982
+ failureUrl: web() + "/checkout/failed?orderId=" + String(order._id),
5983
+ },
5984
+ { secret, env: esewaEnv },
5985
+ );
5986
+ res.json(form);
5987
+ }));
5988
+
5989
+ // POST /checkout/:id/esewa/verify \u2014 verify the base64 \`data\` eSewa returned; mark paid.
5990
+ router.post("/:id/esewa/verify", asyncHandler(async (req, res) => {
5991
+ const { data } = v.object({ data: v.string().min(1) }).parse(req.body);
5992
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
5993
+ if (!order) throw new HttpError(404, "Order not found");
5994
+ const secret = process.env.ESEWA_SECRET ?? ESEWA_TEST_SECRET;
5995
+ const result = await verifyResponse(data, secret);
5996
+ const status = String((result.data as { status?: string }).status ?? "");
5997
+ if (!result.valid || status !== "COMPLETE") throw new HttpError(400, "Payment could not be verified");
5998
+ order.status = "paid";
5999
+ await order.save();
6000
+ res.json({ id: String(order._id), status: "paid" });
6001
+ }));
6002
+
6003
+ // POST /checkout/:id/khalti \u2014 start a Khalti payment (needs KHALTI_SECRET, else 501).
6004
+ router.post("/:id/khalti", asyncHandler(async (req, res) => {
6005
+ const secretKey = process.env.KHALTI_SECRET;
6006
+ if (!secretKey) throw new HttpError(501, "Set KHALTI_SECRET in .env to enable Khalti (see .env.example).");
6007
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
6008
+ if (!order) throw new HttpError(404, "Order not found");
6009
+ order.gateway = "khalti";
6010
+ await order.save();
6011
+ const r = await initiate(
6012
+ {
6013
+ return_url: web() + "/checkout/success?orderId=" + String(order._id),
6014
+ website_url: web(),
6015
+ amount: order.amountPaisa,
6016
+ purchase_order_id: String(order._id),
6017
+ purchase_order_name: order.label,
6018
+ },
6019
+ { secretKey, env: "test" },
6020
+ );
6021
+ order.ref = r.pidx;
6022
+ await order.save();
6023
+ res.json({ paymentUrl: r.payment_url, pidx: r.pidx });
6024
+ }));
6025
+
6026
+ // POST /checkout/:id/khalti/verify \u2014 authoritative lookup by pidx; mark paid.
6027
+ router.post("/:id/khalti/verify", asyncHandler(async (req, res) => {
6028
+ const secretKey = process.env.KHALTI_SECRET;
6029
+ if (!secretKey) throw new HttpError(501, "Khalti is not configured");
6030
+ const { pidx } = v.object({ pidx: v.string().min(1) }).parse(req.body);
6031
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
6032
+ if (!order) throw new HttpError(404, "Order not found");
6033
+ const r = await lookup(pidx, { secretKey, env: "test" });
6034
+ if (r.status !== "Completed") throw new HttpError(400, "Payment status: " + String(r.status));
6035
+ order.status = "paid";
6036
+ await order.save();
6037
+ res.json({ id: String(order._id), status: "paid" });
6038
+ }));
6039
+
6040
+ export default router;
6041
+ `;
6042
+ var checkoutPage = () => `"use client";
6043
+ import { useState } from "react";
6044
+ import { getToken } from "@/lib/api";
6045
+
6046
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
6047
+ const msgOf = (e: unknown) => (e instanceof Error ? e.message : "Something went wrong");
6048
+
6049
+ async function call<T>(path: string, body: unknown): Promise<T> {
6050
+ const token = getToken();
6051
+ const res = await fetch(API + path, {
6052
+ method: "POST",
6053
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
6054
+ body: JSON.stringify(body),
6055
+ });
6056
+ const data: unknown = await res.json().catch(() => ({}));
6057
+ if (!res.ok) throw new Error((data as { error?: string }).error ?? "Request failed");
6058
+ return data as T;
6059
+ }
6060
+
6061
+ export default function CheckoutPage() {
6062
+ const [label, setLabel] = useState("Pro plan");
6063
+ const [amount, setAmount] = useState(1000);
6064
+ const [orderId, setOrderId] = useState<string | null>(null);
6065
+ const [msg, setMsg] = useState<string | null>(null);
6066
+
6067
+ async function createOrder(e: React.FormEvent) {
6068
+ e.preventDefault();
6069
+ try {
6070
+ const o = await call<{ id: string; display: string }>("/checkout", { label, amount });
6071
+ setOrderId(o.id);
6072
+ setMsg("Order " + o.display + " created \u2014 choose how to pay.");
6073
+ } catch (err) { setMsg(msgOf(err)); }
6074
+ }
6075
+ async function payEsewa() {
6076
+ if (!orderId) return;
6077
+ try {
6078
+ const form = await call<{ action: string; fields: Record<string, string> }>("/checkout/" + orderId + "/esewa", {});
6079
+ const f = document.createElement("form");
6080
+ f.method = "POST";
6081
+ f.action = form.action;
6082
+ for (const [k, val] of Object.entries(form.fields)) {
6083
+ const i = document.createElement("input");
6084
+ i.type = "hidden"; i.name = k; i.value = val;
6085
+ f.appendChild(i);
6086
+ }
6087
+ document.body.appendChild(f);
6088
+ f.submit();
6089
+ } catch (err) { setMsg(msgOf(err)); }
6090
+ }
6091
+ async function payKhalti() {
6092
+ if (!orderId) return;
6093
+ try {
6094
+ const r = await call<{ paymentUrl: string }>("/checkout/" + orderId + "/khalti", {});
6095
+ window.location.href = r.paymentUrl;
6096
+ } catch (err) { setMsg(msgOf(err)); }
6097
+ }
6098
+
6099
+ return (
6100
+ <main className="mx-auto max-w-md px-6 py-16">
6101
+ <h1 className="text-2xl font-bold">Checkout</h1>
6102
+ <p className="mt-1 text-sm text-muted">Sign in first, then create an order and pay.</p>
6103
+ <form onSubmit={createOrder} className="mt-6 space-y-3 rounded-2xl border border-hairline p-5">
6104
+ <input value={label} onChange={(e) => setLabel(e.target.value)} placeholder="What are you buying?" className="w-full rounded-xl border border-hairline bg-surface px-4 py-2 outline-none" />
6105
+ <input type="number" min={10} value={amount} onChange={(e) => setAmount(Number(e.target.value))} placeholder="Amount (NPR)" className="w-full rounded-xl border border-hairline bg-surface px-4 py-2 outline-none" />
6106
+ <button className="w-full rounded-full gradient-bg px-4 py-2 font-semibold on-accent">Create order</button>
6107
+ </form>
6108
+ {msg && <p className="mt-4 text-sm text-muted">{msg}</p>}
6109
+ {orderId && (
6110
+ <div className="mt-6 space-y-3">
6111
+ <button onClick={payEsewa} className="w-full rounded-full border border-hairline px-4 py-3 font-semibold transition hover:bg-surface">Pay with eSewa <span className="text-muted">(works in test)</span></button>
6112
+ <button onClick={payKhalti} className="w-full rounded-full border border-hairline px-4 py-3 font-semibold transition hover:bg-surface">Pay with Khalti</button>
6113
+ </div>
6114
+ )}
6115
+ <p className="mt-6 text-xs text-muted">eSewa works out-of-the-box in test mode. Khalti needs KHALTI_SECRET in .env.</p>
6116
+ </main>
6117
+ );
6118
+ }
6119
+ `;
6120
+ var checkoutSuccessPage = () => `"use client";
6121
+ import { useEffect, useState } from "react";
6122
+ import Link from "next/link";
6123
+ import { getToken } from "@/lib/api";
6124
+
6125
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
6126
+
6127
+ export default function CheckoutSuccessPage() {
6128
+ const [status, setStatus] = useState<"verifying" | "paid" | "failed">("verifying");
6129
+ const [detail, setDetail] = useState("");
6130
+
6131
+ useEffect(() => {
6132
+ const q = new URLSearchParams(window.location.search);
6133
+ const orderId = q.get("orderId");
6134
+ const data = q.get("data"); // eSewa returns a base64 \`data\` param
6135
+ const pidx = q.get("pidx"); // Khalti returns \`pidx\`
6136
+ const token = getToken();
6137
+ if (!orderId || !token) { setStatus("failed"); setDetail("Missing order or session \u2014 please sign in."); return; }
6138
+ (async () => {
6139
+ let path = ""; let body: Record<string, string> = {};
6140
+ if (data) { path = "/checkout/" + orderId + "/esewa/verify"; body = { data }; }
6141
+ else if (pidx) { path = "/checkout/" + orderId + "/khalti/verify"; body = { pidx }; }
6142
+ else { setStatus("failed"); setDetail("No payment token was returned."); return; }
6143
+ try {
6144
+ const res = await fetch(API + path, {
6145
+ method: "POST",
6146
+ headers: { "Content-Type": "application/json", Authorization: "Bearer " + token },
6147
+ body: JSON.stringify(body),
6148
+ });
6149
+ if (!res.ok) { const e: unknown = await res.json().catch(() => ({})); throw new Error((e as { error?: string }).error ?? "Verification failed"); }
6150
+ setStatus("paid");
6151
+ } catch (e) { setStatus("failed"); setDetail(e instanceof Error ? e.message : "Verification failed"); }
6152
+ })();
6153
+ }, []);
6154
+
6155
+ return (
6156
+ <main className="mx-auto max-w-md px-6 py-24 text-center">
6157
+ {status === "verifying" && <p className="text-muted">Verifying your payment\u2026</p>}
6158
+ {status === "paid" && (<><h1 className="text-3xl font-bold">Payment successful \u{1F389}</h1><p className="mt-2 text-muted">Thank you \u2014 your order is paid.</p></>)}
6159
+ {status === "failed" && (<><h1 className="text-2xl font-bold">Payment not completed</h1><p className="mt-2 text-muted">{detail}</p></>)}
6160
+ <Link href="/checkout" className="mt-6 inline-block rounded-full border border-hairline px-4 py-2 text-sm">Back to checkout</Link>
6161
+ </main>
6162
+ );
6163
+ }
6164
+ `;
6165
+ var checkoutFailedPage = () => `import Link from "next/link";
6166
+
6167
+ export default function CheckoutFailedPage() {
6168
+ return (
6169
+ <main className="mx-auto max-w-md px-6 py-24 text-center">
6170
+ <h1 className="text-2xl font-bold">Payment cancelled</h1>
6171
+ <p className="mt-2 text-muted">Your payment was not completed.</p>
6172
+ <Link href="/checkout" className="mt-6 inline-block rounded-full border border-hairline px-4 py-2 text-sm">Try again</Link>
6173
+ </main>
6174
+ );
6175
+ }
6176
+ `;
6177
+ var mailerLib = (ctx) => `import { createTransport, createJsonTransport, mailerFromEnv, type Transport } from "@lacspace/mailer";
6178
+ import { welcomeEmail, verifyEmail, passwordResetEmail, toPlainText } from "@lacspace/email-templates";
6179
+
6180
+ // how this works: if SMTP_* env vars are set, it sends real email over SMTP; otherwise
6181
+ // it logs the full message to the console (so the app runs with NO credentials). Adding
6182
+ // SMTP_* later switches to real delivery with no code change.
6183
+ export const transport: Transport = process.env.SMTP_HOST
6184
+ ? createTransport(mailerFromEnv())
6185
+ : createJsonTransport((json) => console.log("\\n[email:dev] set SMTP_* in .env to deliver for real:\\n" + json + "\\n"));
6186
+
6187
+ const FROM = process.env.SMTP_FROM ?? ${JSON.stringify(ctx.name + " <no-reply@example.com>")};
6188
+ const brand = { brandName: ${JSON.stringify(ctx.name)} };
6189
+
6190
+ export function sendWelcome(to: string, name?: string) {
6191
+ const html = welcomeEmail({ name, message: "Thanks for joining " + brand.brandName + "!", ...brand });
6192
+ return transport.send({ from: FROM, to, subject: "Welcome to " + brand.brandName, html, text: toPlainText(html) });
6193
+ }
6194
+ export function sendVerify(to: string, verifyUrl: string) {
6195
+ const html = verifyEmail({ verifyUrl, expiresMinutes: 30, ...brand });
6196
+ return transport.send({ from: FROM, to, subject: "Verify your email", html, text: toPlainText(html) });
6197
+ }
6198
+ export function sendReset(to: string, resetUrl: string) {
6199
+ const html = passwordResetEmail({ resetUrl, expiresMinutes: 30, ...brand });
6200
+ return transport.send({ from: FROM, to, subject: "Reset your password", html, text: toPlainText(html) });
6201
+ }
6202
+ `;
6203
+ var emailRoutes = () => `import express from "express";
6204
+ import { validateEmail } from "@lacspace/email-validate";
6205
+ import { asyncHandler, HttpError } from "../http.js";
6206
+ import { sendWelcome } from "../mail/mailer.js";
6207
+
6208
+ const router = express.Router();
6209
+
6210
+ // POST /email/test \u2014 send a sample welcome email (validates the address first).
6211
+ router.post("/test", asyncHandler(async (req, res) => {
6212
+ const to = String((req.body ?? {}).to ?? "").trim();
6213
+ const check = validateEmail(to);
6214
+ if (!check.valid) throw new HttpError(400, check.reason ?? "Invalid email address");
6215
+ const result = await sendWelcome(check.normalized ?? to);
6216
+ res.json({ ok: true, messageId: result.messageId });
6217
+ }));
6218
+
6219
+ export default router;
6220
+ `;
6221
+ var emailTestPage = () => `"use client";
6222
+ import { useState } from "react";
6223
+ import { getToken } from "@/lib/api";
6224
+
6225
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
6226
+
6227
+ export default function EmailTestPage() {
6228
+ const [to, setTo] = useState("");
6229
+ const [msg, setMsg] = useState<string | null>(null);
6230
+ const [busy, setBusy] = useState(false);
6231
+
6232
+ async function send(e: React.FormEvent) {
6233
+ e.preventDefault();
6234
+ setBusy(true); setMsg(null);
6235
+ try {
6236
+ const token = getToken();
6237
+ const res = await fetch(API + "/email/test", {
6238
+ method: "POST",
6239
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
6240
+ body: JSON.stringify({ to }),
6241
+ });
6242
+ const data: unknown = await res.json().catch(() => ({}));
6243
+ if (!res.ok) throw new Error((data as { error?: string }).error ?? "Failed to send");
6244
+ setMsg("Sent! If no SMTP is configured, check the API console for the email.");
6245
+ } catch (err) { setMsg(err instanceof Error ? err.message : "Failed"); }
6246
+ finally { setBusy(false); }
6247
+ }
6248
+
6249
+ return (
6250
+ <main className="mx-auto max-w-md px-6 py-16">
6251
+ <h1 className="text-2xl font-bold">Send a test email</h1>
6252
+ <p className="mt-1 text-muted">With no SMTP configured, the email is logged to the API console.</p>
6253
+ <form onSubmit={send} className="mt-6 space-y-3">
6254
+ <input type="email" required value={to} onChange={(e) => setTo(e.target.value)} placeholder="you@example.com" className="w-full rounded-xl border border-hairline bg-surface px-4 py-3 outline-none" />
6255
+ <button disabled={busy} className="w-full rounded-full gradient-bg px-4 py-3 font-semibold on-accent disabled:opacity-60">{busy ? "Sending\u2026" : "Send test email"}</button>
6256
+ </form>
6257
+ {msg && <p className="mt-4 text-sm text-muted">{msg}</p>}
6258
+ </main>
6259
+ );
6260
+ }
6261
+ `;
5830
6262
  var scope = (ctx) => `@${ctx.name}`;
5831
6263
  var typesPkgJson = (ctx) => JSON.stringify({
5832
6264
  name: `${scope(ctx)}/types`,
@@ -6804,7 +7236,9 @@ function parseArgs(list) {
6804
7236
  } else if (arg.startsWith("--mode=")) {
6805
7237
  const m = arg.slice(7).toLowerCase();
6806
7238
  a.mode = m === "dynamic" ? "dynamic" : "static";
6807
- } else if (arg === "-h" || arg === "--help") a.help = true;
7239
+ } else if (arg === "--recipe") a.recipe = next();
7240
+ else if (arg.startsWith("--recipe=")) a.recipe = arg.slice(9);
7241
+ else if (arg === "-h" || arg === "--help") a.help = true;
6808
7242
  else if (arg.startsWith("--template=")) a.template = arg.slice(11);
6809
7243
  else if (arg.startsWith("--theme=")) a.theme = arg.slice(8);
6810
7244
  else if (arg.startsWith("--accent=")) a.theme = arg.slice(9);
@@ -6908,6 +7342,7 @@ ${c("bold", "Options")}
6908
7342
  MongoDB/Redis backend (JWT auth + CRUD) + shared types
6909
7343
  ${c("dim", "(alias --dynamic; default is --static, a single Next.js app)")}
6910
7344
  --with <a,b> Feature add-ons, comma-separated (alias --features)
7345
+ --recipe <key> Start from a curated stack (${RECIPES.map((r) => r.key).join(" | ")})
6911
7346
  --theme <name|hex> Accent: a preset, a "#hex", or "from,to" (e.g. --theme lacspace)
6912
7347
  --pm <npm|pnpm|yarn|bun> Package manager (default npm)
6913
7348
  --no-install Skip installing dependencies
@@ -6916,9 +7351,13 @@ ${c("bold", "Options")}
6916
7351
  -h, --help Show this help
6917
7352
 
6918
7353
  ${c("bold", "Feature add-ons")} ${c("dim", "(--with) \u2014 free & keyless, local by default")}
6919
- ${FEATURES.map((f) => ` ${f.key.padEnd(10)} ${f.description}`).join("\n")}
7354
+ ${FEATURES.map((f) => ` ${f.key.padEnd(11)} ${f.description}`).join("\n")}
6920
7355
  ${c("dim", "e.g. npx create-lacspace-app my-app --template saas --with ai-chat,rag")}
6921
7356
 
7357
+ ${c("bold", "Recipes")} ${c("dim", "(--recipe) \u2014 a whole product in one command")}
7358
+ ${RECIPES.map((r) => ` ${r.key.padEnd(14)} ${r.description}`).join("\n")}
7359
+ ${c("dim", "e.g. npx create-lacspace-app my-app --recipe ai-saas")}
7360
+
6922
7361
  ${c("bold", "Themes")} ${c("dim", "(--theme)")}
6923
7362
  ${Object.keys(THEMES).join(" \xB7 ")}
6924
7363
  ${c("dim", 'or a custom colour: --theme "#ff6a00" \xB7 --theme "#0bb9d9,#7c3aed"')}
@@ -7295,11 +7734,16 @@ async function main() {
7295
7734
  stdout.write(`
7296
7735
  ${c("bold", c("magenta", "\u25C6 create-lacspace-app"))} ${c("dim", "\u2014 a gorgeous Next.js starter, batteries wired")}
7297
7736
 
7737
+ `);
7738
+ const recipe = args.recipe ? RECIPES.find((r) => r.key === args.recipe.toLowerCase()) : void 0;
7739
+ if (args.recipe && !recipe) stdout.write(c("yellow", ` ! Unknown recipe "${args.recipe}" \u2014 ignoring. Try: ${RECIPES.map((r) => r.key).join(", ")}
7740
+ `));
7741
+ if (recipe) stdout.write(` ${c("green", "\u2714")} Recipe ${c("cyan", recipe.key)} ${c("dim", "\u2014 " + recipe.description)}
7298
7742
  `);
7299
7743
  let name = args.name;
7300
- let templateKey = args.template;
7301
- let mode = args.mode ?? "static";
7302
- const featureKeys = [...args.features];
7744
+ let templateKey = args.template ?? recipe?.template;
7745
+ let mode = args.mode ?? recipe?.mode ?? "static";
7746
+ const featureKeys = [...recipe?.features ?? [], ...args.features];
7303
7747
  if (!args.yes && stdin.isTTY) {
7304
7748
  const rl = createInterface({ input: stdin, output: stdout });
7305
7749
  try {
@@ -7315,7 +7759,7 @@ ${c("green", "?")} Template ${c("dim", "(1)")}: `)).trim() || "1";
7315
7759
  const idx = /^\d+$/.test(ans) ? parseInt(ans, 10) - 1 : TEMPLATES.findIndex((t) => t.key === ans);
7316
7760
  templateKey = TEMPLATES[idx]?.key ?? "personal";
7317
7761
  }
7318
- if (args.mode === void 0) {
7762
+ if (args.mode === void 0 && !recipe) {
7319
7763
  stdout.write(`
7320
7764
  What kind of app?
7321
7765
  `);
@@ -7368,6 +7812,11 @@ ${c("green", "?")} Add features? ${c("dim", "(comma-separated numbers, or Enter
7368
7812
  return;
7369
7813
  }
7370
7814
  const features = normalizeFeatures(featureKeys);
7815
+ if (mode === "static" && features.some((f) => f.requiresBackend)) {
7816
+ mode = "dynamic";
7817
+ stdout.write(` ${c("dim", "\u2191 a selected add-on needs a backend \u2014 building full-stack")}
7818
+ `);
7819
+ }
7371
7820
  const files = buildFiles({ name: projectName, template, features, mode });
7372
7821
  for (const [rel, content] of Object.entries(files)) {
7373
7822
  const full = join(dir, rel);
@@ -7456,4 +7905,4 @@ if (invokedAsCli) {
7456
7905
  });
7457
7906
  }
7458
7907
 
7459
- export { FEATURES, SECTIONS, TEMPLATES, applyFeatures, buildFiles, normalizeFeatures, resolveContext };
7908
+ export { FEATURES, RECIPES, SECTIONS, TEMPLATES, applyFeatures, buildFiles, normalizeFeatures, resolveContext };