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