create-lacspace-app 2.6.0 → 2.7.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,10 @@ 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.7 — payments & email add-ons.**
24
+ > - **`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`.
25
+ > - **`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.
26
+
23
27
  > **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
28
  > - **`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
29
  > - **`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
@@ -2209,6 +2209,71 @@ var FEATURES = [
2209
2209
  "It's cookieless and stores no personal data \u2014 privacy-first by default."
2210
2210
  ],
2211
2211
  learn: "https://developer.lacspace.com/packages/analytics-lite"
2212
+ },
2213
+ {
2214
+ key: "payments",
2215
+ label: "Payments (Nepal)",
2216
+ 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.",
2217
+ requiresBackend: true,
2218
+ deps: {},
2219
+ files: () => ({
2220
+ "app/checkout/page.tsx": checkoutPage(),
2221
+ "app/checkout/success/page.tsx": checkoutSuccessPage(),
2222
+ "app/checkout/failed/page.tsx": checkoutFailedPage()
2223
+ }),
2224
+ backend: () => ({
2225
+ deps: { "@lacspace/esewa": "^1.2.0", "@lacspace/khalti": "^1.1.0", "@lacspace/money": "^1.1.0" },
2226
+ files: {
2227
+ "src/models/order.ts": ordersModel(),
2228
+ "src/routes/checkout.ts": checkoutRoutes()
2229
+ },
2230
+ routes: [{ path: "/checkout", handler: "checkoutRoutes", auth: true, importLine: 'import checkoutRoutes from "./checkout.js";' }],
2231
+ env: {
2232
+ ESEWA_MERCHANT_CODE: "eSewa merchant/product code (blank = eSewa TEST sandbox).",
2233
+ ESEWA_SECRET: "eSewa secret key (blank = TEST sandbox \u2014 payments work end-to-end in test).",
2234
+ KHALTI_SECRET: "Khalti secret key (required to enable Khalti; blank shows a 'configure Khalti' message)."
2235
+ }
2236
+ }),
2237
+ nextSteps: [
2238
+ "Sign in, then open http://localhost:3000/checkout and pay with eSewa \u2014 it works in TEST mode with no credentials.",
2239
+ "Enable Khalti by setting KHALTI_SECRET in .env (get a test key from https://khalti.com).",
2240
+ "Go live: set ESEWA_MERCHANT_CODE + ESEWA_SECRET (and a live KHALTI_SECRET)."
2241
+ ],
2242
+ learn: "https://developer.lacspace.com/packages/esewa"
2243
+ },
2244
+ {
2245
+ key: "email",
2246
+ label: "Email",
2247
+ 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.",
2248
+ requiresBackend: true,
2249
+ deps: {},
2250
+ files: () => ({ "app/email-test/page.tsx": emailTestPage() }),
2251
+ backend: (ctx) => ({
2252
+ deps: {
2253
+ "@lacspace/mailer": "^1.2.0",
2254
+ "@lacspace/email-templates": "^1.1.0",
2255
+ "@lacspace/email-validate": "^1.1.0"
2256
+ },
2257
+ files: {
2258
+ "src/mail/mailer.ts": mailerLib(ctx),
2259
+ "src/routes/email.ts": emailRoutes()
2260
+ },
2261
+ routes: [{ path: "/email", handler: "emailRoutes", auth: true, importLine: 'import emailRoutes from "./email.js";' }],
2262
+ env: {
2263
+ SMTP_HOST: "SMTP host (blank in dev \u2014 emails are logged to the API console instead of sent).",
2264
+ SMTP_PORT: "SMTP port (default 587).",
2265
+ SMTP_SECURE: "true for implicit TLS on port 465; otherwise false.",
2266
+ SMTP_USER: "SMTP username.",
2267
+ SMTP_PASS: "SMTP password.",
2268
+ SMTP_FROM: 'Default From address, e.g. "Acme <no-reply@acme.com>".'
2269
+ }
2270
+ }),
2271
+ nextSteps: [
2272
+ "Sign in, then open http://localhost:3000/email-test and send yourself a sample email.",
2273
+ "With no SMTP_* set, the email is printed to the API console (dev). Set SMTP_* in .env to send for real.",
2274
+ "Reuse the helpers in backend/src/mail/mailer.ts: sendWelcome / sendVerify / sendReset."
2275
+ ],
2276
+ learn: "https://developer.lacspace.com/packages/mailer"
2212
2277
  }
2213
2278
  ];
2214
2279
  var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
@@ -5827,6 +5892,365 @@ export default function AnalyticsPage() {
5827
5892
  );
5828
5893
  }
5829
5894
  `;
5895
+ var ordersModel = () => `import mongoose from "mongoose";
5896
+ import { uuidv7 } from "@lacspace/id";
5897
+
5898
+ export interface OrderDoc {
5899
+ _id: string;
5900
+ userId: string;
5901
+ label: string;
5902
+ amountPaisa: number;
5903
+ currency: string;
5904
+ status: "pending" | "paid" | "failed";
5905
+ gateway: string;
5906
+ ref: string;
5907
+ createdAt: Date;
5908
+ updatedAt: Date;
5909
+ }
5910
+
5911
+ const schema = new mongoose.Schema<OrderDoc>(
5912
+ {
5913
+ _id: { type: String, default: () => uuidv7() },
5914
+ userId: { type: String, required: true, index: true },
5915
+ label: { type: String, required: true },
5916
+ amountPaisa: { type: Number, required: true },
5917
+ currency: { type: String, default: "NPR" },
5918
+ status: { type: String, enum: ["pending", "paid", "failed"], default: "pending" },
5919
+ gateway: { type: String, default: "" },
5920
+ ref: { type: String, default: "" },
5921
+ },
5922
+ { timestamps: true },
5923
+ );
5924
+
5925
+ export const Order =
5926
+ (mongoose.models.Order as mongoose.Model<OrderDoc>) ?? mongoose.model<OrderDoc>("Order", schema);
5927
+ `;
5928
+ var checkoutRoutes = () => `import express from "express";
5929
+ import {
5930
+ buildForm, verifyResponse, paisaToRupees, generateTransactionUuid,
5931
+ ESEWA_TEST_SECRET, ESEWA_TEST_PRODUCT_CODE,
5932
+ } from "@lacspace/esewa";
5933
+ import { initiate, lookup } from "@lacspace/khalti";
5934
+ import { Money, formatBasic } from "@lacspace/money";
5935
+ import { v } from "@lacspace/validate";
5936
+ import { asyncHandler, HttpError } from "../http.js";
5937
+ import { env } from "../env.js";
5938
+ import { Order } from "../models/order.js";
5939
+
5940
+ // how this works: create an order, then start a gateway payment. eSewa works in TEST
5941
+ // mode with the package's baked-in sandbox keys (no credentials!). Khalti needs a real
5942
+ // secret. After the customer returns, the frontend calls /verify \u2014 never trust the
5943
+ // redirect alone, always confirm server-side.
5944
+ const router = express.Router();
5945
+ const web = () => env.CORS_ORIGIN.split(",")[0]!.trim();
5946
+
5947
+ const CreateInput = v.object({ label: v.string().min(1).max(120), amount: v.number().positive() });
5948
+
5949
+ // POST /checkout \u2014 create a pending order (amount in RUPEES from the UI \u2192 stored as paisa).
5950
+ router.post("/", asyncHandler(async (req, res) => {
5951
+ const { label, amount } = CreateInput.parse(req.body);
5952
+ const amountPaisa = Money.of(amount, "NPR").toMinor();
5953
+ const order = await Order.create({ userId: req.user!.sub, label, amountPaisa });
5954
+ res.status(201).json({ id: String(order._id), label, amountPaisa, display: formatBasic(Money.fromMinor(amountPaisa, "NPR")) });
5955
+ }));
5956
+
5957
+ // POST /checkout/:id/esewa \u2014 build the signed eSewa form to auto-POST from the browser.
5958
+ router.post("/:id/esewa", asyncHandler(async (req, res) => {
5959
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
5960
+ if (!order) throw new HttpError(404, "Order not found");
5961
+ const secret = process.env.ESEWA_SECRET ?? ESEWA_TEST_SECRET;
5962
+ const productCode = process.env.ESEWA_MERCHANT_CODE ?? ESEWA_TEST_PRODUCT_CODE;
5963
+ const esewaEnv: "test" | "prod" = process.env.ESEWA_SECRET ? "prod" : "test";
5964
+ const uuid = generateTransactionUuid();
5965
+ order.ref = uuid;
5966
+ order.gateway = "esewa";
5967
+ await order.save();
5968
+ const form = await buildForm(
5969
+ {
5970
+ amount: paisaToRupees(order.amountPaisa),
5971
+ transactionUuid: uuid,
5972
+ productCode,
5973
+ successUrl: web() + "/checkout/success?orderId=" + String(order._id),
5974
+ failureUrl: web() + "/checkout/failed?orderId=" + String(order._id),
5975
+ },
5976
+ { secret, env: esewaEnv },
5977
+ );
5978
+ res.json(form);
5979
+ }));
5980
+
5981
+ // POST /checkout/:id/esewa/verify \u2014 verify the base64 \`data\` eSewa returned; mark paid.
5982
+ router.post("/:id/esewa/verify", asyncHandler(async (req, res) => {
5983
+ const { data } = v.object({ data: v.string().min(1) }).parse(req.body);
5984
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
5985
+ if (!order) throw new HttpError(404, "Order not found");
5986
+ const secret = process.env.ESEWA_SECRET ?? ESEWA_TEST_SECRET;
5987
+ const result = await verifyResponse(data, secret);
5988
+ const status = String((result.data as { status?: string }).status ?? "");
5989
+ if (!result.valid || status !== "COMPLETE") throw new HttpError(400, "Payment could not be verified");
5990
+ order.status = "paid";
5991
+ await order.save();
5992
+ res.json({ id: String(order._id), status: "paid" });
5993
+ }));
5994
+
5995
+ // POST /checkout/:id/khalti \u2014 start a Khalti payment (needs KHALTI_SECRET, else 501).
5996
+ router.post("/:id/khalti", asyncHandler(async (req, res) => {
5997
+ const secretKey = process.env.KHALTI_SECRET;
5998
+ if (!secretKey) throw new HttpError(501, "Set KHALTI_SECRET in .env to enable Khalti (see .env.example).");
5999
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
6000
+ if (!order) throw new HttpError(404, "Order not found");
6001
+ order.gateway = "khalti";
6002
+ await order.save();
6003
+ const r = await initiate(
6004
+ {
6005
+ return_url: web() + "/checkout/success?orderId=" + String(order._id),
6006
+ website_url: web(),
6007
+ amount: order.amountPaisa,
6008
+ purchase_order_id: String(order._id),
6009
+ purchase_order_name: order.label,
6010
+ },
6011
+ { secretKey, env: "test" },
6012
+ );
6013
+ order.ref = r.pidx;
6014
+ await order.save();
6015
+ res.json({ paymentUrl: r.payment_url, pidx: r.pidx });
6016
+ }));
6017
+
6018
+ // POST /checkout/:id/khalti/verify \u2014 authoritative lookup by pidx; mark paid.
6019
+ router.post("/:id/khalti/verify", asyncHandler(async (req, res) => {
6020
+ const secretKey = process.env.KHALTI_SECRET;
6021
+ if (!secretKey) throw new HttpError(501, "Khalti is not configured");
6022
+ const { pidx } = v.object({ pidx: v.string().min(1) }).parse(req.body);
6023
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
6024
+ if (!order) throw new HttpError(404, "Order not found");
6025
+ const r = await lookup(pidx, { secretKey, env: "test" });
6026
+ if (r.status !== "Completed") throw new HttpError(400, "Payment status: " + String(r.status));
6027
+ order.status = "paid";
6028
+ await order.save();
6029
+ res.json({ id: String(order._id), status: "paid" });
6030
+ }));
6031
+
6032
+ export default router;
6033
+ `;
6034
+ var checkoutPage = () => `"use client";
6035
+ import { useState } from "react";
6036
+ import { getToken } from "@/lib/api";
6037
+
6038
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
6039
+ const msgOf = (e: unknown) => (e instanceof Error ? e.message : "Something went wrong");
6040
+
6041
+ async function call<T>(path: string, body: unknown): Promise<T> {
6042
+ const token = getToken();
6043
+ const res = await fetch(API + path, {
6044
+ method: "POST",
6045
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
6046
+ body: JSON.stringify(body),
6047
+ });
6048
+ const data: unknown = await res.json().catch(() => ({}));
6049
+ if (!res.ok) throw new Error((data as { error?: string }).error ?? "Request failed");
6050
+ return data as T;
6051
+ }
6052
+
6053
+ export default function CheckoutPage() {
6054
+ const [label, setLabel] = useState("Pro plan");
6055
+ const [amount, setAmount] = useState(1000);
6056
+ const [orderId, setOrderId] = useState<string | null>(null);
6057
+ const [msg, setMsg] = useState<string | null>(null);
6058
+
6059
+ async function createOrder(e: React.FormEvent) {
6060
+ e.preventDefault();
6061
+ try {
6062
+ const o = await call<{ id: string; display: string }>("/checkout", { label, amount });
6063
+ setOrderId(o.id);
6064
+ setMsg("Order " + o.display + " created \u2014 choose how to pay.");
6065
+ } catch (err) { setMsg(msgOf(err)); }
6066
+ }
6067
+ async function payEsewa() {
6068
+ if (!orderId) return;
6069
+ try {
6070
+ const form = await call<{ action: string; fields: Record<string, string> }>("/checkout/" + orderId + "/esewa", {});
6071
+ const f = document.createElement("form");
6072
+ f.method = "POST";
6073
+ f.action = form.action;
6074
+ for (const [k, val] of Object.entries(form.fields)) {
6075
+ const i = document.createElement("input");
6076
+ i.type = "hidden"; i.name = k; i.value = val;
6077
+ f.appendChild(i);
6078
+ }
6079
+ document.body.appendChild(f);
6080
+ f.submit();
6081
+ } catch (err) { setMsg(msgOf(err)); }
6082
+ }
6083
+ async function payKhalti() {
6084
+ if (!orderId) return;
6085
+ try {
6086
+ const r = await call<{ paymentUrl: string }>("/checkout/" + orderId + "/khalti", {});
6087
+ window.location.href = r.paymentUrl;
6088
+ } catch (err) { setMsg(msgOf(err)); }
6089
+ }
6090
+
6091
+ return (
6092
+ <main className="mx-auto max-w-md px-6 py-16">
6093
+ <h1 className="text-2xl font-bold">Checkout</h1>
6094
+ <p className="mt-1 text-sm text-muted">Sign in first, then create an order and pay.</p>
6095
+ <form onSubmit={createOrder} className="mt-6 space-y-3 rounded-2xl border border-hairline p-5">
6096
+ <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" />
6097
+ <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" />
6098
+ <button className="w-full rounded-full gradient-bg px-4 py-2 font-semibold on-accent">Create order</button>
6099
+ </form>
6100
+ {msg && <p className="mt-4 text-sm text-muted">{msg}</p>}
6101
+ {orderId && (
6102
+ <div className="mt-6 space-y-3">
6103
+ <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>
6104
+ <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>
6105
+ </div>
6106
+ )}
6107
+ <p className="mt-6 text-xs text-muted">eSewa works out-of-the-box in test mode. Khalti needs KHALTI_SECRET in .env.</p>
6108
+ </main>
6109
+ );
6110
+ }
6111
+ `;
6112
+ var checkoutSuccessPage = () => `"use client";
6113
+ import { useEffect, useState } from "react";
6114
+ import Link from "next/link";
6115
+ import { getToken } from "@/lib/api";
6116
+
6117
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
6118
+
6119
+ export default function CheckoutSuccessPage() {
6120
+ const [status, setStatus] = useState<"verifying" | "paid" | "failed">("verifying");
6121
+ const [detail, setDetail] = useState("");
6122
+
6123
+ useEffect(() => {
6124
+ const q = new URLSearchParams(window.location.search);
6125
+ const orderId = q.get("orderId");
6126
+ const data = q.get("data"); // eSewa returns a base64 \`data\` param
6127
+ const pidx = q.get("pidx"); // Khalti returns \`pidx\`
6128
+ const token = getToken();
6129
+ if (!orderId || !token) { setStatus("failed"); setDetail("Missing order or session \u2014 please sign in."); return; }
6130
+ (async () => {
6131
+ let path = ""; let body: Record<string, string> = {};
6132
+ if (data) { path = "/checkout/" + orderId + "/esewa/verify"; body = { data }; }
6133
+ else if (pidx) { path = "/checkout/" + orderId + "/khalti/verify"; body = { pidx }; }
6134
+ else { setStatus("failed"); setDetail("No payment token was returned."); return; }
6135
+ try {
6136
+ const res = await fetch(API + path, {
6137
+ method: "POST",
6138
+ headers: { "Content-Type": "application/json", Authorization: "Bearer " + token },
6139
+ body: JSON.stringify(body),
6140
+ });
6141
+ if (!res.ok) { const e: unknown = await res.json().catch(() => ({})); throw new Error((e as { error?: string }).error ?? "Verification failed"); }
6142
+ setStatus("paid");
6143
+ } catch (e) { setStatus("failed"); setDetail(e instanceof Error ? e.message : "Verification failed"); }
6144
+ })();
6145
+ }, []);
6146
+
6147
+ return (
6148
+ <main className="mx-auto max-w-md px-6 py-24 text-center">
6149
+ {status === "verifying" && <p className="text-muted">Verifying your payment\u2026</p>}
6150
+ {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></>)}
6151
+ {status === "failed" && (<><h1 className="text-2xl font-bold">Payment not completed</h1><p className="mt-2 text-muted">{detail}</p></>)}
6152
+ <Link href="/checkout" className="mt-6 inline-block rounded-full border border-hairline px-4 py-2 text-sm">Back to checkout</Link>
6153
+ </main>
6154
+ );
6155
+ }
6156
+ `;
6157
+ var checkoutFailedPage = () => `import Link from "next/link";
6158
+
6159
+ export default function CheckoutFailedPage() {
6160
+ return (
6161
+ <main className="mx-auto max-w-md px-6 py-24 text-center">
6162
+ <h1 className="text-2xl font-bold">Payment cancelled</h1>
6163
+ <p className="mt-2 text-muted">Your payment was not completed.</p>
6164
+ <Link href="/checkout" className="mt-6 inline-block rounded-full border border-hairline px-4 py-2 text-sm">Try again</Link>
6165
+ </main>
6166
+ );
6167
+ }
6168
+ `;
6169
+ var mailerLib = (ctx) => `import { createTransport, createJsonTransport, mailerFromEnv, type Transport } from "@lacspace/mailer";
6170
+ import { welcomeEmail, verifyEmail, passwordResetEmail, toPlainText } from "@lacspace/email-templates";
6171
+
6172
+ // how this works: if SMTP_* env vars are set, it sends real email over SMTP; otherwise
6173
+ // it logs the full message to the console (so the app runs with NO credentials). Adding
6174
+ // SMTP_* later switches to real delivery with no code change.
6175
+ export const transport: Transport = process.env.SMTP_HOST
6176
+ ? createTransport(mailerFromEnv())
6177
+ : createJsonTransport((json) => console.log("\\n[email:dev] set SMTP_* in .env to deliver for real:\\n" + json + "\\n"));
6178
+
6179
+ const FROM = process.env.SMTP_FROM ?? ${JSON.stringify(ctx.name + " <no-reply@example.com>")};
6180
+ const brand = { brandName: ${JSON.stringify(ctx.name)} };
6181
+
6182
+ export function sendWelcome(to: string, name?: string) {
6183
+ const html = welcomeEmail({ name, message: "Thanks for joining " + brand.brandName + "!", ...brand });
6184
+ return transport.send({ from: FROM, to, subject: "Welcome to " + brand.brandName, html, text: toPlainText(html) });
6185
+ }
6186
+ export function sendVerify(to: string, verifyUrl: string) {
6187
+ const html = verifyEmail({ verifyUrl, expiresMinutes: 30, ...brand });
6188
+ return transport.send({ from: FROM, to, subject: "Verify your email", html, text: toPlainText(html) });
6189
+ }
6190
+ export function sendReset(to: string, resetUrl: string) {
6191
+ const html = passwordResetEmail({ resetUrl, expiresMinutes: 30, ...brand });
6192
+ return transport.send({ from: FROM, to, subject: "Reset your password", html, text: toPlainText(html) });
6193
+ }
6194
+ `;
6195
+ var emailRoutes = () => `import express from "express";
6196
+ import { validateEmail } from "@lacspace/email-validate";
6197
+ import { asyncHandler, HttpError } from "../http.js";
6198
+ import { sendWelcome } from "../mail/mailer.js";
6199
+
6200
+ const router = express.Router();
6201
+
6202
+ // POST /email/test \u2014 send a sample welcome email (validates the address first).
6203
+ router.post("/test", asyncHandler(async (req, res) => {
6204
+ const to = String((req.body ?? {}).to ?? "").trim();
6205
+ const check = validateEmail(to);
6206
+ if (!check.valid) throw new HttpError(400, check.reason ?? "Invalid email address");
6207
+ const result = await sendWelcome(check.normalized ?? to);
6208
+ res.json({ ok: true, messageId: result.messageId });
6209
+ }));
6210
+
6211
+ export default router;
6212
+ `;
6213
+ var emailTestPage = () => `"use client";
6214
+ import { useState } from "react";
6215
+ import { getToken } from "@/lib/api";
6216
+
6217
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
6218
+
6219
+ export default function EmailTestPage() {
6220
+ const [to, setTo] = useState("");
6221
+ const [msg, setMsg] = useState<string | null>(null);
6222
+ const [busy, setBusy] = useState(false);
6223
+
6224
+ async function send(e: React.FormEvent) {
6225
+ e.preventDefault();
6226
+ setBusy(true); setMsg(null);
6227
+ try {
6228
+ const token = getToken();
6229
+ const res = await fetch(API + "/email/test", {
6230
+ method: "POST",
6231
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
6232
+ body: JSON.stringify({ to }),
6233
+ });
6234
+ const data: unknown = await res.json().catch(() => ({}));
6235
+ if (!res.ok) throw new Error((data as { error?: string }).error ?? "Failed to send");
6236
+ setMsg("Sent! If no SMTP is configured, check the API console for the email.");
6237
+ } catch (err) { setMsg(err instanceof Error ? err.message : "Failed"); }
6238
+ finally { setBusy(false); }
6239
+ }
6240
+
6241
+ return (
6242
+ <main className="mx-auto max-w-md px-6 py-16">
6243
+ <h1 className="text-2xl font-bold">Send a test email</h1>
6244
+ <p className="mt-1 text-muted">With no SMTP configured, the email is logged to the API console.</p>
6245
+ <form onSubmit={send} className="mt-6 space-y-3">
6246
+ <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" />
6247
+ <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>
6248
+ </form>
6249
+ {msg && <p className="mt-4 text-sm text-muted">{msg}</p>}
6250
+ </main>
6251
+ );
6252
+ }
6253
+ `;
5830
6254
  var scope = (ctx) => `@${ctx.name}`;
5831
6255
  var typesPkgJson = (ctx) => JSON.stringify({
5832
6256
  name: `${scope(ctx)}/types`,
package/dist/lib.cjs CHANGED
@@ -2213,6 +2213,71 @@ var FEATURES = [
2213
2213
  "It's cookieless and stores no personal data \u2014 privacy-first by default."
2214
2214
  ],
2215
2215
  learn: "https://developer.lacspace.com/packages/analytics-lite"
2216
+ },
2217
+ {
2218
+ key: "payments",
2219
+ label: "Payments (Nepal)",
2220
+ 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.",
2221
+ requiresBackend: true,
2222
+ deps: {},
2223
+ files: () => ({
2224
+ "app/checkout/page.tsx": checkoutPage(),
2225
+ "app/checkout/success/page.tsx": checkoutSuccessPage(),
2226
+ "app/checkout/failed/page.tsx": checkoutFailedPage()
2227
+ }),
2228
+ backend: () => ({
2229
+ deps: { "@lacspace/esewa": "^1.2.0", "@lacspace/khalti": "^1.1.0", "@lacspace/money": "^1.1.0" },
2230
+ files: {
2231
+ "src/models/order.ts": ordersModel(),
2232
+ "src/routes/checkout.ts": checkoutRoutes()
2233
+ },
2234
+ routes: [{ path: "/checkout", handler: "checkoutRoutes", auth: true, importLine: 'import checkoutRoutes from "./checkout.js";' }],
2235
+ env: {
2236
+ ESEWA_MERCHANT_CODE: "eSewa merchant/product code (blank = eSewa TEST sandbox).",
2237
+ ESEWA_SECRET: "eSewa secret key (blank = TEST sandbox \u2014 payments work end-to-end in test).",
2238
+ KHALTI_SECRET: "Khalti secret key (required to enable Khalti; blank shows a 'configure Khalti' message)."
2239
+ }
2240
+ }),
2241
+ nextSteps: [
2242
+ "Sign in, then open http://localhost:3000/checkout and pay with eSewa \u2014 it works in TEST mode with no credentials.",
2243
+ "Enable Khalti by setting KHALTI_SECRET in .env (get a test key from https://khalti.com).",
2244
+ "Go live: set ESEWA_MERCHANT_CODE + ESEWA_SECRET (and a live KHALTI_SECRET)."
2245
+ ],
2246
+ learn: "https://developer.lacspace.com/packages/esewa"
2247
+ },
2248
+ {
2249
+ key: "email",
2250
+ label: "Email",
2251
+ 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.",
2252
+ requiresBackend: true,
2253
+ deps: {},
2254
+ files: () => ({ "app/email-test/page.tsx": emailTestPage() }),
2255
+ backend: (ctx) => ({
2256
+ deps: {
2257
+ "@lacspace/mailer": "^1.2.0",
2258
+ "@lacspace/email-templates": "^1.1.0",
2259
+ "@lacspace/email-validate": "^1.1.0"
2260
+ },
2261
+ files: {
2262
+ "src/mail/mailer.ts": mailerLib(ctx),
2263
+ "src/routes/email.ts": emailRoutes()
2264
+ },
2265
+ routes: [{ path: "/email", handler: "emailRoutes", auth: true, importLine: 'import emailRoutes from "./email.js";' }],
2266
+ env: {
2267
+ SMTP_HOST: "SMTP host (blank in dev \u2014 emails are logged to the API console instead of sent).",
2268
+ SMTP_PORT: "SMTP port (default 587).",
2269
+ SMTP_SECURE: "true for implicit TLS on port 465; otherwise false.",
2270
+ SMTP_USER: "SMTP username.",
2271
+ SMTP_PASS: "SMTP password.",
2272
+ SMTP_FROM: 'Default From address, e.g. "Acme <no-reply@acme.com>".'
2273
+ }
2274
+ }),
2275
+ nextSteps: [
2276
+ "Sign in, then open http://localhost:3000/email-test and send yourself a sample email.",
2277
+ "With no SMTP_* set, the email is printed to the API console (dev). Set SMTP_* in .env to send for real.",
2278
+ "Reuse the helpers in backend/src/mail/mailer.ts: sendWelcome / sendVerify / sendReset."
2279
+ ],
2280
+ learn: "https://developer.lacspace.com/packages/mailer"
2216
2281
  }
2217
2282
  ];
2218
2283
  var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
@@ -5831,6 +5896,365 @@ export default function AnalyticsPage() {
5831
5896
  );
5832
5897
  }
5833
5898
  `;
5899
+ var ordersModel = () => `import mongoose from "mongoose";
5900
+ import { uuidv7 } from "@lacspace/id";
5901
+
5902
+ export interface OrderDoc {
5903
+ _id: string;
5904
+ userId: string;
5905
+ label: string;
5906
+ amountPaisa: number;
5907
+ currency: string;
5908
+ status: "pending" | "paid" | "failed";
5909
+ gateway: string;
5910
+ ref: string;
5911
+ createdAt: Date;
5912
+ updatedAt: Date;
5913
+ }
5914
+
5915
+ const schema = new mongoose.Schema<OrderDoc>(
5916
+ {
5917
+ _id: { type: String, default: () => uuidv7() },
5918
+ userId: { type: String, required: true, index: true },
5919
+ label: { type: String, required: true },
5920
+ amountPaisa: { type: Number, required: true },
5921
+ currency: { type: String, default: "NPR" },
5922
+ status: { type: String, enum: ["pending", "paid", "failed"], default: "pending" },
5923
+ gateway: { type: String, default: "" },
5924
+ ref: { type: String, default: "" },
5925
+ },
5926
+ { timestamps: true },
5927
+ );
5928
+
5929
+ export const Order =
5930
+ (mongoose.models.Order as mongoose.Model<OrderDoc>) ?? mongoose.model<OrderDoc>("Order", schema);
5931
+ `;
5932
+ var checkoutRoutes = () => `import express from "express";
5933
+ import {
5934
+ buildForm, verifyResponse, paisaToRupees, generateTransactionUuid,
5935
+ ESEWA_TEST_SECRET, ESEWA_TEST_PRODUCT_CODE,
5936
+ } from "@lacspace/esewa";
5937
+ import { initiate, lookup } from "@lacspace/khalti";
5938
+ import { Money, formatBasic } from "@lacspace/money";
5939
+ import { v } from "@lacspace/validate";
5940
+ import { asyncHandler, HttpError } from "../http.js";
5941
+ import { env } from "../env.js";
5942
+ import { Order } from "../models/order.js";
5943
+
5944
+ // how this works: create an order, then start a gateway payment. eSewa works in TEST
5945
+ // mode with the package's baked-in sandbox keys (no credentials!). Khalti needs a real
5946
+ // secret. After the customer returns, the frontend calls /verify \u2014 never trust the
5947
+ // redirect alone, always confirm server-side.
5948
+ const router = express.Router();
5949
+ const web = () => env.CORS_ORIGIN.split(",")[0]!.trim();
5950
+
5951
+ const CreateInput = v.object({ label: v.string().min(1).max(120), amount: v.number().positive() });
5952
+
5953
+ // POST /checkout \u2014 create a pending order (amount in RUPEES from the UI \u2192 stored as paisa).
5954
+ router.post("/", asyncHandler(async (req, res) => {
5955
+ const { label, amount } = CreateInput.parse(req.body);
5956
+ const amountPaisa = Money.of(amount, "NPR").toMinor();
5957
+ const order = await Order.create({ userId: req.user!.sub, label, amountPaisa });
5958
+ res.status(201).json({ id: String(order._id), label, amountPaisa, display: formatBasic(Money.fromMinor(amountPaisa, "NPR")) });
5959
+ }));
5960
+
5961
+ // POST /checkout/:id/esewa \u2014 build the signed eSewa form to auto-POST from the browser.
5962
+ router.post("/:id/esewa", asyncHandler(async (req, res) => {
5963
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
5964
+ if (!order) throw new HttpError(404, "Order not found");
5965
+ const secret = process.env.ESEWA_SECRET ?? ESEWA_TEST_SECRET;
5966
+ const productCode = process.env.ESEWA_MERCHANT_CODE ?? ESEWA_TEST_PRODUCT_CODE;
5967
+ const esewaEnv: "test" | "prod" = process.env.ESEWA_SECRET ? "prod" : "test";
5968
+ const uuid = generateTransactionUuid();
5969
+ order.ref = uuid;
5970
+ order.gateway = "esewa";
5971
+ await order.save();
5972
+ const form = await buildForm(
5973
+ {
5974
+ amount: paisaToRupees(order.amountPaisa),
5975
+ transactionUuid: uuid,
5976
+ productCode,
5977
+ successUrl: web() + "/checkout/success?orderId=" + String(order._id),
5978
+ failureUrl: web() + "/checkout/failed?orderId=" + String(order._id),
5979
+ },
5980
+ { secret, env: esewaEnv },
5981
+ );
5982
+ res.json(form);
5983
+ }));
5984
+
5985
+ // POST /checkout/:id/esewa/verify \u2014 verify the base64 \`data\` eSewa returned; mark paid.
5986
+ router.post("/:id/esewa/verify", asyncHandler(async (req, res) => {
5987
+ const { data } = v.object({ data: v.string().min(1) }).parse(req.body);
5988
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
5989
+ if (!order) throw new HttpError(404, "Order not found");
5990
+ const secret = process.env.ESEWA_SECRET ?? ESEWA_TEST_SECRET;
5991
+ const result = await verifyResponse(data, secret);
5992
+ const status = String((result.data as { status?: string }).status ?? "");
5993
+ if (!result.valid || status !== "COMPLETE") throw new HttpError(400, "Payment could not be verified");
5994
+ order.status = "paid";
5995
+ await order.save();
5996
+ res.json({ id: String(order._id), status: "paid" });
5997
+ }));
5998
+
5999
+ // POST /checkout/:id/khalti \u2014 start a Khalti payment (needs KHALTI_SECRET, else 501).
6000
+ router.post("/:id/khalti", asyncHandler(async (req, res) => {
6001
+ const secretKey = process.env.KHALTI_SECRET;
6002
+ if (!secretKey) throw new HttpError(501, "Set KHALTI_SECRET in .env to enable Khalti (see .env.example).");
6003
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
6004
+ if (!order) throw new HttpError(404, "Order not found");
6005
+ order.gateway = "khalti";
6006
+ await order.save();
6007
+ const r = await initiate(
6008
+ {
6009
+ return_url: web() + "/checkout/success?orderId=" + String(order._id),
6010
+ website_url: web(),
6011
+ amount: order.amountPaisa,
6012
+ purchase_order_id: String(order._id),
6013
+ purchase_order_name: order.label,
6014
+ },
6015
+ { secretKey, env: "test" },
6016
+ );
6017
+ order.ref = r.pidx;
6018
+ await order.save();
6019
+ res.json({ paymentUrl: r.payment_url, pidx: r.pidx });
6020
+ }));
6021
+
6022
+ // POST /checkout/:id/khalti/verify \u2014 authoritative lookup by pidx; mark paid.
6023
+ router.post("/:id/khalti/verify", asyncHandler(async (req, res) => {
6024
+ const secretKey = process.env.KHALTI_SECRET;
6025
+ if (!secretKey) throw new HttpError(501, "Khalti is not configured");
6026
+ const { pidx } = v.object({ pidx: v.string().min(1) }).parse(req.body);
6027
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
6028
+ if (!order) throw new HttpError(404, "Order not found");
6029
+ const r = await lookup(pidx, { secretKey, env: "test" });
6030
+ if (r.status !== "Completed") throw new HttpError(400, "Payment status: " + String(r.status));
6031
+ order.status = "paid";
6032
+ await order.save();
6033
+ res.json({ id: String(order._id), status: "paid" });
6034
+ }));
6035
+
6036
+ export default router;
6037
+ `;
6038
+ var checkoutPage = () => `"use client";
6039
+ import { useState } from "react";
6040
+ import { getToken } from "@/lib/api";
6041
+
6042
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
6043
+ const msgOf = (e: unknown) => (e instanceof Error ? e.message : "Something went wrong");
6044
+
6045
+ async function call<T>(path: string, body: unknown): Promise<T> {
6046
+ const token = getToken();
6047
+ const res = await fetch(API + path, {
6048
+ method: "POST",
6049
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
6050
+ body: JSON.stringify(body),
6051
+ });
6052
+ const data: unknown = await res.json().catch(() => ({}));
6053
+ if (!res.ok) throw new Error((data as { error?: string }).error ?? "Request failed");
6054
+ return data as T;
6055
+ }
6056
+
6057
+ export default function CheckoutPage() {
6058
+ const [label, setLabel] = useState("Pro plan");
6059
+ const [amount, setAmount] = useState(1000);
6060
+ const [orderId, setOrderId] = useState<string | null>(null);
6061
+ const [msg, setMsg] = useState<string | null>(null);
6062
+
6063
+ async function createOrder(e: React.FormEvent) {
6064
+ e.preventDefault();
6065
+ try {
6066
+ const o = await call<{ id: string; display: string }>("/checkout", { label, amount });
6067
+ setOrderId(o.id);
6068
+ setMsg("Order " + o.display + " created \u2014 choose how to pay.");
6069
+ } catch (err) { setMsg(msgOf(err)); }
6070
+ }
6071
+ async function payEsewa() {
6072
+ if (!orderId) return;
6073
+ try {
6074
+ const form = await call<{ action: string; fields: Record<string, string> }>("/checkout/" + orderId + "/esewa", {});
6075
+ const f = document.createElement("form");
6076
+ f.method = "POST";
6077
+ f.action = form.action;
6078
+ for (const [k, val] of Object.entries(form.fields)) {
6079
+ const i = document.createElement("input");
6080
+ i.type = "hidden"; i.name = k; i.value = val;
6081
+ f.appendChild(i);
6082
+ }
6083
+ document.body.appendChild(f);
6084
+ f.submit();
6085
+ } catch (err) { setMsg(msgOf(err)); }
6086
+ }
6087
+ async function payKhalti() {
6088
+ if (!orderId) return;
6089
+ try {
6090
+ const r = await call<{ paymentUrl: string }>("/checkout/" + orderId + "/khalti", {});
6091
+ window.location.href = r.paymentUrl;
6092
+ } catch (err) { setMsg(msgOf(err)); }
6093
+ }
6094
+
6095
+ return (
6096
+ <main className="mx-auto max-w-md px-6 py-16">
6097
+ <h1 className="text-2xl font-bold">Checkout</h1>
6098
+ <p className="mt-1 text-sm text-muted">Sign in first, then create an order and pay.</p>
6099
+ <form onSubmit={createOrder} className="mt-6 space-y-3 rounded-2xl border border-hairline p-5">
6100
+ <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" />
6101
+ <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" />
6102
+ <button className="w-full rounded-full gradient-bg px-4 py-2 font-semibold on-accent">Create order</button>
6103
+ </form>
6104
+ {msg && <p className="mt-4 text-sm text-muted">{msg}</p>}
6105
+ {orderId && (
6106
+ <div className="mt-6 space-y-3">
6107
+ <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>
6108
+ <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>
6109
+ </div>
6110
+ )}
6111
+ <p className="mt-6 text-xs text-muted">eSewa works out-of-the-box in test mode. Khalti needs KHALTI_SECRET in .env.</p>
6112
+ </main>
6113
+ );
6114
+ }
6115
+ `;
6116
+ var checkoutSuccessPage = () => `"use client";
6117
+ import { useEffect, useState } from "react";
6118
+ import Link from "next/link";
6119
+ import { getToken } from "@/lib/api";
6120
+
6121
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
6122
+
6123
+ export default function CheckoutSuccessPage() {
6124
+ const [status, setStatus] = useState<"verifying" | "paid" | "failed">("verifying");
6125
+ const [detail, setDetail] = useState("");
6126
+
6127
+ useEffect(() => {
6128
+ const q = new URLSearchParams(window.location.search);
6129
+ const orderId = q.get("orderId");
6130
+ const data = q.get("data"); // eSewa returns a base64 \`data\` param
6131
+ const pidx = q.get("pidx"); // Khalti returns \`pidx\`
6132
+ const token = getToken();
6133
+ if (!orderId || !token) { setStatus("failed"); setDetail("Missing order or session \u2014 please sign in."); return; }
6134
+ (async () => {
6135
+ let path = ""; let body: Record<string, string> = {};
6136
+ if (data) { path = "/checkout/" + orderId + "/esewa/verify"; body = { data }; }
6137
+ else if (pidx) { path = "/checkout/" + orderId + "/khalti/verify"; body = { pidx }; }
6138
+ else { setStatus("failed"); setDetail("No payment token was returned."); return; }
6139
+ try {
6140
+ const res = await fetch(API + path, {
6141
+ method: "POST",
6142
+ headers: { "Content-Type": "application/json", Authorization: "Bearer " + token },
6143
+ body: JSON.stringify(body),
6144
+ });
6145
+ if (!res.ok) { const e: unknown = await res.json().catch(() => ({})); throw new Error((e as { error?: string }).error ?? "Verification failed"); }
6146
+ setStatus("paid");
6147
+ } catch (e) { setStatus("failed"); setDetail(e instanceof Error ? e.message : "Verification failed"); }
6148
+ })();
6149
+ }, []);
6150
+
6151
+ return (
6152
+ <main className="mx-auto max-w-md px-6 py-24 text-center">
6153
+ {status === "verifying" && <p className="text-muted">Verifying your payment\u2026</p>}
6154
+ {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></>)}
6155
+ {status === "failed" && (<><h1 className="text-2xl font-bold">Payment not completed</h1><p className="mt-2 text-muted">{detail}</p></>)}
6156
+ <Link href="/checkout" className="mt-6 inline-block rounded-full border border-hairline px-4 py-2 text-sm">Back to checkout</Link>
6157
+ </main>
6158
+ );
6159
+ }
6160
+ `;
6161
+ var checkoutFailedPage = () => `import Link from "next/link";
6162
+
6163
+ export default function CheckoutFailedPage() {
6164
+ return (
6165
+ <main className="mx-auto max-w-md px-6 py-24 text-center">
6166
+ <h1 className="text-2xl font-bold">Payment cancelled</h1>
6167
+ <p className="mt-2 text-muted">Your payment was not completed.</p>
6168
+ <Link href="/checkout" className="mt-6 inline-block rounded-full border border-hairline px-4 py-2 text-sm">Try again</Link>
6169
+ </main>
6170
+ );
6171
+ }
6172
+ `;
6173
+ var mailerLib = (ctx) => `import { createTransport, createJsonTransport, mailerFromEnv, type Transport } from "@lacspace/mailer";
6174
+ import { welcomeEmail, verifyEmail, passwordResetEmail, toPlainText } from "@lacspace/email-templates";
6175
+
6176
+ // how this works: if SMTP_* env vars are set, it sends real email over SMTP; otherwise
6177
+ // it logs the full message to the console (so the app runs with NO credentials). Adding
6178
+ // SMTP_* later switches to real delivery with no code change.
6179
+ export const transport: Transport = process.env.SMTP_HOST
6180
+ ? createTransport(mailerFromEnv())
6181
+ : createJsonTransport((json) => console.log("\\n[email:dev] set SMTP_* in .env to deliver for real:\\n" + json + "\\n"));
6182
+
6183
+ const FROM = process.env.SMTP_FROM ?? ${JSON.stringify(ctx.name + " <no-reply@example.com>")};
6184
+ const brand = { brandName: ${JSON.stringify(ctx.name)} };
6185
+
6186
+ export function sendWelcome(to: string, name?: string) {
6187
+ const html = welcomeEmail({ name, message: "Thanks for joining " + brand.brandName + "!", ...brand });
6188
+ return transport.send({ from: FROM, to, subject: "Welcome to " + brand.brandName, html, text: toPlainText(html) });
6189
+ }
6190
+ export function sendVerify(to: string, verifyUrl: string) {
6191
+ const html = verifyEmail({ verifyUrl, expiresMinutes: 30, ...brand });
6192
+ return transport.send({ from: FROM, to, subject: "Verify your email", html, text: toPlainText(html) });
6193
+ }
6194
+ export function sendReset(to: string, resetUrl: string) {
6195
+ const html = passwordResetEmail({ resetUrl, expiresMinutes: 30, ...brand });
6196
+ return transport.send({ from: FROM, to, subject: "Reset your password", html, text: toPlainText(html) });
6197
+ }
6198
+ `;
6199
+ var emailRoutes = () => `import express from "express";
6200
+ import { validateEmail } from "@lacspace/email-validate";
6201
+ import { asyncHandler, HttpError } from "../http.js";
6202
+ import { sendWelcome } from "../mail/mailer.js";
6203
+
6204
+ const router = express.Router();
6205
+
6206
+ // POST /email/test \u2014 send a sample welcome email (validates the address first).
6207
+ router.post("/test", asyncHandler(async (req, res) => {
6208
+ const to = String((req.body ?? {}).to ?? "").trim();
6209
+ const check = validateEmail(to);
6210
+ if (!check.valid) throw new HttpError(400, check.reason ?? "Invalid email address");
6211
+ const result = await sendWelcome(check.normalized ?? to);
6212
+ res.json({ ok: true, messageId: result.messageId });
6213
+ }));
6214
+
6215
+ export default router;
6216
+ `;
6217
+ var emailTestPage = () => `"use client";
6218
+ import { useState } from "react";
6219
+ import { getToken } from "@/lib/api";
6220
+
6221
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
6222
+
6223
+ export default function EmailTestPage() {
6224
+ const [to, setTo] = useState("");
6225
+ const [msg, setMsg] = useState<string | null>(null);
6226
+ const [busy, setBusy] = useState(false);
6227
+
6228
+ async function send(e: React.FormEvent) {
6229
+ e.preventDefault();
6230
+ setBusy(true); setMsg(null);
6231
+ try {
6232
+ const token = getToken();
6233
+ const res = await fetch(API + "/email/test", {
6234
+ method: "POST",
6235
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
6236
+ body: JSON.stringify({ to }),
6237
+ });
6238
+ const data: unknown = await res.json().catch(() => ({}));
6239
+ if (!res.ok) throw new Error((data as { error?: string }).error ?? "Failed to send");
6240
+ setMsg("Sent! If no SMTP is configured, check the API console for the email.");
6241
+ } catch (err) { setMsg(err instanceof Error ? err.message : "Failed"); }
6242
+ finally { setBusy(false); }
6243
+ }
6244
+
6245
+ return (
6246
+ <main className="mx-auto max-w-md px-6 py-16">
6247
+ <h1 className="text-2xl font-bold">Send a test email</h1>
6248
+ <p className="mt-1 text-muted">With no SMTP configured, the email is logged to the API console.</p>
6249
+ <form onSubmit={send} className="mt-6 space-y-3">
6250
+ <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" />
6251
+ <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>
6252
+ </form>
6253
+ {msg && <p className="mt-4 text-sm text-muted">{msg}</p>}
6254
+ </main>
6255
+ );
6256
+ }
6257
+ `;
5834
6258
  var scope = (ctx) => `@${ctx.name}`;
5835
6259
  var typesPkgJson = (ctx) => JSON.stringify({
5836
6260
  name: `${scope(ctx)}/types`,
package/dist/lib.js CHANGED
@@ -2210,6 +2210,71 @@ var FEATURES = [
2210
2210
  "It's cookieless and stores no personal data \u2014 privacy-first by default."
2211
2211
  ],
2212
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"
2213
2278
  }
2214
2279
  ];
2215
2280
  var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
@@ -5828,6 +5893,365 @@ export default function AnalyticsPage() {
5828
5893
  );
5829
5894
  }
5830
5895
  `;
5896
+ var ordersModel = () => `import mongoose from "mongoose";
5897
+ import { uuidv7 } from "@lacspace/id";
5898
+
5899
+ export interface OrderDoc {
5900
+ _id: string;
5901
+ userId: string;
5902
+ label: string;
5903
+ amountPaisa: number;
5904
+ currency: string;
5905
+ status: "pending" | "paid" | "failed";
5906
+ gateway: string;
5907
+ ref: string;
5908
+ createdAt: Date;
5909
+ updatedAt: Date;
5910
+ }
5911
+
5912
+ const schema = new mongoose.Schema<OrderDoc>(
5913
+ {
5914
+ _id: { type: String, default: () => uuidv7() },
5915
+ userId: { type: String, required: true, index: true },
5916
+ label: { type: String, required: true },
5917
+ amountPaisa: { type: Number, required: true },
5918
+ currency: { type: String, default: "NPR" },
5919
+ status: { type: String, enum: ["pending", "paid", "failed"], default: "pending" },
5920
+ gateway: { type: String, default: "" },
5921
+ ref: { type: String, default: "" },
5922
+ },
5923
+ { timestamps: true },
5924
+ );
5925
+
5926
+ export const Order =
5927
+ (mongoose.models.Order as mongoose.Model<OrderDoc>) ?? mongoose.model<OrderDoc>("Order", schema);
5928
+ `;
5929
+ var checkoutRoutes = () => `import express from "express";
5930
+ import {
5931
+ buildForm, verifyResponse, paisaToRupees, generateTransactionUuid,
5932
+ ESEWA_TEST_SECRET, ESEWA_TEST_PRODUCT_CODE,
5933
+ } from "@lacspace/esewa";
5934
+ import { initiate, lookup } from "@lacspace/khalti";
5935
+ import { Money, formatBasic } from "@lacspace/money";
5936
+ import { v } from "@lacspace/validate";
5937
+ import { asyncHandler, HttpError } from "../http.js";
5938
+ import { env } from "../env.js";
5939
+ import { Order } from "../models/order.js";
5940
+
5941
+ // how this works: create an order, then start a gateway payment. eSewa works in TEST
5942
+ // mode with the package's baked-in sandbox keys (no credentials!). Khalti needs a real
5943
+ // secret. After the customer returns, the frontend calls /verify \u2014 never trust the
5944
+ // redirect alone, always confirm server-side.
5945
+ const router = express.Router();
5946
+ const web = () => env.CORS_ORIGIN.split(",")[0]!.trim();
5947
+
5948
+ const CreateInput = v.object({ label: v.string().min(1).max(120), amount: v.number().positive() });
5949
+
5950
+ // POST /checkout \u2014 create a pending order (amount in RUPEES from the UI \u2192 stored as paisa).
5951
+ router.post("/", asyncHandler(async (req, res) => {
5952
+ const { label, amount } = CreateInput.parse(req.body);
5953
+ const amountPaisa = Money.of(amount, "NPR").toMinor();
5954
+ const order = await Order.create({ userId: req.user!.sub, label, amountPaisa });
5955
+ res.status(201).json({ id: String(order._id), label, amountPaisa, display: formatBasic(Money.fromMinor(amountPaisa, "NPR")) });
5956
+ }));
5957
+
5958
+ // POST /checkout/:id/esewa \u2014 build the signed eSewa form to auto-POST from the browser.
5959
+ router.post("/:id/esewa", asyncHandler(async (req, res) => {
5960
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
5961
+ if (!order) throw new HttpError(404, "Order not found");
5962
+ const secret = process.env.ESEWA_SECRET ?? ESEWA_TEST_SECRET;
5963
+ const productCode = process.env.ESEWA_MERCHANT_CODE ?? ESEWA_TEST_PRODUCT_CODE;
5964
+ const esewaEnv: "test" | "prod" = process.env.ESEWA_SECRET ? "prod" : "test";
5965
+ const uuid = generateTransactionUuid();
5966
+ order.ref = uuid;
5967
+ order.gateway = "esewa";
5968
+ await order.save();
5969
+ const form = await buildForm(
5970
+ {
5971
+ amount: paisaToRupees(order.amountPaisa),
5972
+ transactionUuid: uuid,
5973
+ productCode,
5974
+ successUrl: web() + "/checkout/success?orderId=" + String(order._id),
5975
+ failureUrl: web() + "/checkout/failed?orderId=" + String(order._id),
5976
+ },
5977
+ { secret, env: esewaEnv },
5978
+ );
5979
+ res.json(form);
5980
+ }));
5981
+
5982
+ // POST /checkout/:id/esewa/verify \u2014 verify the base64 \`data\` eSewa returned; mark paid.
5983
+ router.post("/:id/esewa/verify", asyncHandler(async (req, res) => {
5984
+ const { data } = v.object({ data: v.string().min(1) }).parse(req.body);
5985
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
5986
+ if (!order) throw new HttpError(404, "Order not found");
5987
+ const secret = process.env.ESEWA_SECRET ?? ESEWA_TEST_SECRET;
5988
+ const result = await verifyResponse(data, secret);
5989
+ const status = String((result.data as { status?: string }).status ?? "");
5990
+ if (!result.valid || status !== "COMPLETE") throw new HttpError(400, "Payment could not be verified");
5991
+ order.status = "paid";
5992
+ await order.save();
5993
+ res.json({ id: String(order._id), status: "paid" });
5994
+ }));
5995
+
5996
+ // POST /checkout/:id/khalti \u2014 start a Khalti payment (needs KHALTI_SECRET, else 501).
5997
+ router.post("/:id/khalti", asyncHandler(async (req, res) => {
5998
+ const secretKey = process.env.KHALTI_SECRET;
5999
+ if (!secretKey) throw new HttpError(501, "Set KHALTI_SECRET in .env to enable Khalti (see .env.example).");
6000
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
6001
+ if (!order) throw new HttpError(404, "Order not found");
6002
+ order.gateway = "khalti";
6003
+ await order.save();
6004
+ const r = await initiate(
6005
+ {
6006
+ return_url: web() + "/checkout/success?orderId=" + String(order._id),
6007
+ website_url: web(),
6008
+ amount: order.amountPaisa,
6009
+ purchase_order_id: String(order._id),
6010
+ purchase_order_name: order.label,
6011
+ },
6012
+ { secretKey, env: "test" },
6013
+ );
6014
+ order.ref = r.pidx;
6015
+ await order.save();
6016
+ res.json({ paymentUrl: r.payment_url, pidx: r.pidx });
6017
+ }));
6018
+
6019
+ // POST /checkout/:id/khalti/verify \u2014 authoritative lookup by pidx; mark paid.
6020
+ router.post("/:id/khalti/verify", asyncHandler(async (req, res) => {
6021
+ const secretKey = process.env.KHALTI_SECRET;
6022
+ if (!secretKey) throw new HttpError(501, "Khalti is not configured");
6023
+ const { pidx } = v.object({ pidx: v.string().min(1) }).parse(req.body);
6024
+ const order = await Order.findOne({ _id: req.params.id, userId: req.user!.sub });
6025
+ if (!order) throw new HttpError(404, "Order not found");
6026
+ const r = await lookup(pidx, { secretKey, env: "test" });
6027
+ if (r.status !== "Completed") throw new HttpError(400, "Payment status: " + String(r.status));
6028
+ order.status = "paid";
6029
+ await order.save();
6030
+ res.json({ id: String(order._id), status: "paid" });
6031
+ }));
6032
+
6033
+ export default router;
6034
+ `;
6035
+ var checkoutPage = () => `"use client";
6036
+ import { useState } from "react";
6037
+ import { getToken } from "@/lib/api";
6038
+
6039
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
6040
+ const msgOf = (e: unknown) => (e instanceof Error ? e.message : "Something went wrong");
6041
+
6042
+ async function call<T>(path: string, body: unknown): Promise<T> {
6043
+ const token = getToken();
6044
+ const res = await fetch(API + path, {
6045
+ method: "POST",
6046
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
6047
+ body: JSON.stringify(body),
6048
+ });
6049
+ const data: unknown = await res.json().catch(() => ({}));
6050
+ if (!res.ok) throw new Error((data as { error?: string }).error ?? "Request failed");
6051
+ return data as T;
6052
+ }
6053
+
6054
+ export default function CheckoutPage() {
6055
+ const [label, setLabel] = useState("Pro plan");
6056
+ const [amount, setAmount] = useState(1000);
6057
+ const [orderId, setOrderId] = useState<string | null>(null);
6058
+ const [msg, setMsg] = useState<string | null>(null);
6059
+
6060
+ async function createOrder(e: React.FormEvent) {
6061
+ e.preventDefault();
6062
+ try {
6063
+ const o = await call<{ id: string; display: string }>("/checkout", { label, amount });
6064
+ setOrderId(o.id);
6065
+ setMsg("Order " + o.display + " created \u2014 choose how to pay.");
6066
+ } catch (err) { setMsg(msgOf(err)); }
6067
+ }
6068
+ async function payEsewa() {
6069
+ if (!orderId) return;
6070
+ try {
6071
+ const form = await call<{ action: string; fields: Record<string, string> }>("/checkout/" + orderId + "/esewa", {});
6072
+ const f = document.createElement("form");
6073
+ f.method = "POST";
6074
+ f.action = form.action;
6075
+ for (const [k, val] of Object.entries(form.fields)) {
6076
+ const i = document.createElement("input");
6077
+ i.type = "hidden"; i.name = k; i.value = val;
6078
+ f.appendChild(i);
6079
+ }
6080
+ document.body.appendChild(f);
6081
+ f.submit();
6082
+ } catch (err) { setMsg(msgOf(err)); }
6083
+ }
6084
+ async function payKhalti() {
6085
+ if (!orderId) return;
6086
+ try {
6087
+ const r = await call<{ paymentUrl: string }>("/checkout/" + orderId + "/khalti", {});
6088
+ window.location.href = r.paymentUrl;
6089
+ } catch (err) { setMsg(msgOf(err)); }
6090
+ }
6091
+
6092
+ return (
6093
+ <main className="mx-auto max-w-md px-6 py-16">
6094
+ <h1 className="text-2xl font-bold">Checkout</h1>
6095
+ <p className="mt-1 text-sm text-muted">Sign in first, then create an order and pay.</p>
6096
+ <form onSubmit={createOrder} className="mt-6 space-y-3 rounded-2xl border border-hairline p-5">
6097
+ <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" />
6098
+ <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" />
6099
+ <button className="w-full rounded-full gradient-bg px-4 py-2 font-semibold on-accent">Create order</button>
6100
+ </form>
6101
+ {msg && <p className="mt-4 text-sm text-muted">{msg}</p>}
6102
+ {orderId && (
6103
+ <div className="mt-6 space-y-3">
6104
+ <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>
6105
+ <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>
6106
+ </div>
6107
+ )}
6108
+ <p className="mt-6 text-xs text-muted">eSewa works out-of-the-box in test mode. Khalti needs KHALTI_SECRET in .env.</p>
6109
+ </main>
6110
+ );
6111
+ }
6112
+ `;
6113
+ var checkoutSuccessPage = () => `"use client";
6114
+ import { useEffect, useState } from "react";
6115
+ import Link from "next/link";
6116
+ import { getToken } from "@/lib/api";
6117
+
6118
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
6119
+
6120
+ export default function CheckoutSuccessPage() {
6121
+ const [status, setStatus] = useState<"verifying" | "paid" | "failed">("verifying");
6122
+ const [detail, setDetail] = useState("");
6123
+
6124
+ useEffect(() => {
6125
+ const q = new URLSearchParams(window.location.search);
6126
+ const orderId = q.get("orderId");
6127
+ const data = q.get("data"); // eSewa returns a base64 \`data\` param
6128
+ const pidx = q.get("pidx"); // Khalti returns \`pidx\`
6129
+ const token = getToken();
6130
+ if (!orderId || !token) { setStatus("failed"); setDetail("Missing order or session \u2014 please sign in."); return; }
6131
+ (async () => {
6132
+ let path = ""; let body: Record<string, string> = {};
6133
+ if (data) { path = "/checkout/" + orderId + "/esewa/verify"; body = { data }; }
6134
+ else if (pidx) { path = "/checkout/" + orderId + "/khalti/verify"; body = { pidx }; }
6135
+ else { setStatus("failed"); setDetail("No payment token was returned."); return; }
6136
+ try {
6137
+ const res = await fetch(API + path, {
6138
+ method: "POST",
6139
+ headers: { "Content-Type": "application/json", Authorization: "Bearer " + token },
6140
+ body: JSON.stringify(body),
6141
+ });
6142
+ if (!res.ok) { const e: unknown = await res.json().catch(() => ({})); throw new Error((e as { error?: string }).error ?? "Verification failed"); }
6143
+ setStatus("paid");
6144
+ } catch (e) { setStatus("failed"); setDetail(e instanceof Error ? e.message : "Verification failed"); }
6145
+ })();
6146
+ }, []);
6147
+
6148
+ return (
6149
+ <main className="mx-auto max-w-md px-6 py-24 text-center">
6150
+ {status === "verifying" && <p className="text-muted">Verifying your payment\u2026</p>}
6151
+ {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></>)}
6152
+ {status === "failed" && (<><h1 className="text-2xl font-bold">Payment not completed</h1><p className="mt-2 text-muted">{detail}</p></>)}
6153
+ <Link href="/checkout" className="mt-6 inline-block rounded-full border border-hairline px-4 py-2 text-sm">Back to checkout</Link>
6154
+ </main>
6155
+ );
6156
+ }
6157
+ `;
6158
+ var checkoutFailedPage = () => `import Link from "next/link";
6159
+
6160
+ export default function CheckoutFailedPage() {
6161
+ return (
6162
+ <main className="mx-auto max-w-md px-6 py-24 text-center">
6163
+ <h1 className="text-2xl font-bold">Payment cancelled</h1>
6164
+ <p className="mt-2 text-muted">Your payment was not completed.</p>
6165
+ <Link href="/checkout" className="mt-6 inline-block rounded-full border border-hairline px-4 py-2 text-sm">Try again</Link>
6166
+ </main>
6167
+ );
6168
+ }
6169
+ `;
6170
+ var mailerLib = (ctx) => `import { createTransport, createJsonTransport, mailerFromEnv, type Transport } from "@lacspace/mailer";
6171
+ import { welcomeEmail, verifyEmail, passwordResetEmail, toPlainText } from "@lacspace/email-templates";
6172
+
6173
+ // how this works: if SMTP_* env vars are set, it sends real email over SMTP; otherwise
6174
+ // it logs the full message to the console (so the app runs with NO credentials). Adding
6175
+ // SMTP_* later switches to real delivery with no code change.
6176
+ export const transport: Transport = process.env.SMTP_HOST
6177
+ ? createTransport(mailerFromEnv())
6178
+ : createJsonTransport((json) => console.log("\\n[email:dev] set SMTP_* in .env to deliver for real:\\n" + json + "\\n"));
6179
+
6180
+ const FROM = process.env.SMTP_FROM ?? ${JSON.stringify(ctx.name + " <no-reply@example.com>")};
6181
+ const brand = { brandName: ${JSON.stringify(ctx.name)} };
6182
+
6183
+ export function sendWelcome(to: string, name?: string) {
6184
+ const html = welcomeEmail({ name, message: "Thanks for joining " + brand.brandName + "!", ...brand });
6185
+ return transport.send({ from: FROM, to, subject: "Welcome to " + brand.brandName, html, text: toPlainText(html) });
6186
+ }
6187
+ export function sendVerify(to: string, verifyUrl: string) {
6188
+ const html = verifyEmail({ verifyUrl, expiresMinutes: 30, ...brand });
6189
+ return transport.send({ from: FROM, to, subject: "Verify your email", html, text: toPlainText(html) });
6190
+ }
6191
+ export function sendReset(to: string, resetUrl: string) {
6192
+ const html = passwordResetEmail({ resetUrl, expiresMinutes: 30, ...brand });
6193
+ return transport.send({ from: FROM, to, subject: "Reset your password", html, text: toPlainText(html) });
6194
+ }
6195
+ `;
6196
+ var emailRoutes = () => `import express from "express";
6197
+ import { validateEmail } from "@lacspace/email-validate";
6198
+ import { asyncHandler, HttpError } from "../http.js";
6199
+ import { sendWelcome } from "../mail/mailer.js";
6200
+
6201
+ const router = express.Router();
6202
+
6203
+ // POST /email/test \u2014 send a sample welcome email (validates the address first).
6204
+ router.post("/test", asyncHandler(async (req, res) => {
6205
+ const to = String((req.body ?? {}).to ?? "").trim();
6206
+ const check = validateEmail(to);
6207
+ if (!check.valid) throw new HttpError(400, check.reason ?? "Invalid email address");
6208
+ const result = await sendWelcome(check.normalized ?? to);
6209
+ res.json({ ok: true, messageId: result.messageId });
6210
+ }));
6211
+
6212
+ export default router;
6213
+ `;
6214
+ var emailTestPage = () => `"use client";
6215
+ import { useState } from "react";
6216
+ import { getToken } from "@/lib/api";
6217
+
6218
+ const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
6219
+
6220
+ export default function EmailTestPage() {
6221
+ const [to, setTo] = useState("");
6222
+ const [msg, setMsg] = useState<string | null>(null);
6223
+ const [busy, setBusy] = useState(false);
6224
+
6225
+ async function send(e: React.FormEvent) {
6226
+ e.preventDefault();
6227
+ setBusy(true); setMsg(null);
6228
+ try {
6229
+ const token = getToken();
6230
+ const res = await fetch(API + "/email/test", {
6231
+ method: "POST",
6232
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: "Bearer " + token } : {}) },
6233
+ body: JSON.stringify({ to }),
6234
+ });
6235
+ const data: unknown = await res.json().catch(() => ({}));
6236
+ if (!res.ok) throw new Error((data as { error?: string }).error ?? "Failed to send");
6237
+ setMsg("Sent! If no SMTP is configured, check the API console for the email.");
6238
+ } catch (err) { setMsg(err instanceof Error ? err.message : "Failed"); }
6239
+ finally { setBusy(false); }
6240
+ }
6241
+
6242
+ return (
6243
+ <main className="mx-auto max-w-md px-6 py-16">
6244
+ <h1 className="text-2xl font-bold">Send a test email</h1>
6245
+ <p className="mt-1 text-muted">With no SMTP configured, the email is logged to the API console.</p>
6246
+ <form onSubmit={send} className="mt-6 space-y-3">
6247
+ <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" />
6248
+ <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>
6249
+ </form>
6250
+ {msg && <p className="mt-4 text-sm text-muted">{msg}</p>}
6251
+ </main>
6252
+ );
6253
+ }
6254
+ `;
5831
6255
  var scope = (ctx) => `@${ctx.name}`;
5832
6256
  var typesPkgJson = (ctx) => JSON.stringify({
5833
6257
  name: `${scope(ctx)}/types`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-lacspace-app",
3
- "version": "2.6.0",
3
+ "version": "2.7.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": {