kaching-cli 0.1.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/LICENSE +21 -0
- package/README.md +23 -0
- package/assets/sdk.json +1 -0
- package/assets/template/.env.example +4 -0
- package/assets/template/AGENTS.md +54 -0
- package/assets/template/CLAUDE.md +1 -0
- package/assets/template/README.md +11 -0
- package/assets/template/eslint.config.mjs +18 -0
- package/assets/template/gitignore +42 -0
- package/assets/template/next.config.ts +10 -0
- package/assets/template/package.json +30 -0
- package/assets/template/postcss.config.mjs +7 -0
- package/assets/template/src/app/checkout/success/page.tsx +9 -0
- package/assets/template/src/app/favicon.ico +0 -0
- package/assets/template/src/app/globals.css +41 -0
- package/assets/template/src/app/layout.tsx +47 -0
- package/assets/template/src/app/not-found.tsx +13 -0
- package/assets/template/src/app/page.tsx +37 -0
- package/assets/template/src/app/products/[slug]/page.tsx +62 -0
- package/assets/template/src/components/cart-button.tsx +21 -0
- package/assets/template/src/components/cart-drawer.tsx +136 -0
- package/assets/template/src/components/header.tsx +26 -0
- package/assets/template/src/components/order-confirmation.tsx +116 -0
- package/assets/template/src/components/price.tsx +21 -0
- package/assets/template/src/components/product-card.tsx +44 -0
- package/assets/template/src/components/product-form.tsx +123 -0
- package/assets/template/src/components/providers.tsx +14 -0
- package/assets/template/src/lib/kaching.ts +24 -0
- package/assets/template/src/store.config.ts +16 -0
- package/assets/template/tsconfig.json +34 -0
- package/dist/chunk-TWKERERX.js +283 -0
- package/dist/index.js +503 -0
- package/dist/mcp-LTF62ASB.js +313 -0
- package/package.json +54 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { formatMoney, useOrder } from "@kaching.sh/react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
|
|
6
|
+
export function OrderConfirmation({ sessionId }: { sessionId: string | null }) {
|
|
7
|
+
const { order, status } = useOrder(sessionId);
|
|
8
|
+
|
|
9
|
+
if (status === "loading") {
|
|
10
|
+
return (
|
|
11
|
+
<Centered>
|
|
12
|
+
<p className="animate-pulse text-muted">Confirming your payment…</p>
|
|
13
|
+
</Centered>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!order) {
|
|
18
|
+
return (
|
|
19
|
+
<Centered>
|
|
20
|
+
<h1 className="font-display text-4xl">We couldn't find that order</h1>
|
|
21
|
+
<p className="mt-4 text-muted">
|
|
22
|
+
If you were charged, your receipt is on its way by email. Otherwise,{" "}
|
|
23
|
+
<Link href="/" className="underline underline-offset-4">
|
|
24
|
+
return to the shop
|
|
25
|
+
</Link>
|
|
26
|
+
.
|
|
27
|
+
</p>
|
|
28
|
+
</Centered>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const downloads = order.items.flatMap((i) => i.downloads.map((d) => ({ ...d, title: i.title })));
|
|
33
|
+
const address = order.shipping_address;
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="mx-auto max-w-2xl px-4 pt-16 sm:px-6">
|
|
37
|
+
<p className="text-sm uppercase tracking-[0.2em] text-muted">Order #{order.number}</p>
|
|
38
|
+
<h1 className="mt-4 font-display text-5xl leading-tight">Thank you{order.customer_name ? `, ${order.customer_name.split(" ")[0]}` : ""}.</h1>
|
|
39
|
+
<p className="mt-4 text-muted">
|
|
40
|
+
{order.email ? <>A receipt is on its way to {order.email}.</> : "Your order is confirmed."}
|
|
41
|
+
</p>
|
|
42
|
+
|
|
43
|
+
{downloads.length > 0 && (
|
|
44
|
+
<section className="mt-10 rounded-card border border-border bg-surface p-6">
|
|
45
|
+
<h2 className="font-medium">Your downloads</h2>
|
|
46
|
+
<ul className="mt-4 space-y-3">
|
|
47
|
+
{downloads.map((d) => (
|
|
48
|
+
<li key={d.url} className="flex items-center justify-between gap-4">
|
|
49
|
+
<span className="min-w-0 truncate text-sm">{d.filename}</span>
|
|
50
|
+
<a href={d.url} className="shrink-0 rounded-full bg-accent px-4 py-2 text-sm text-accent-foreground hover:opacity-90">
|
|
51
|
+
Download
|
|
52
|
+
</a>
|
|
53
|
+
</li>
|
|
54
|
+
))}
|
|
55
|
+
</ul>
|
|
56
|
+
</section>
|
|
57
|
+
)}
|
|
58
|
+
|
|
59
|
+
<section className="mt-10 border-t border-border pt-8">
|
|
60
|
+
<ul className="space-y-3">
|
|
61
|
+
{order.items.map((i) => (
|
|
62
|
+
<li key={i.id} className="flex justify-between gap-4">
|
|
63
|
+
<span>
|
|
64
|
+
{i.title}
|
|
65
|
+
{i.variant_title && <span className="text-muted"> · {i.variant_title}</span>}
|
|
66
|
+
<span className="text-muted"> × {i.quantity}</span>
|
|
67
|
+
</span>
|
|
68
|
+
<span className="tabular-nums">{formatMoney(i.line_total, order.currency)}</span>
|
|
69
|
+
</li>
|
|
70
|
+
))}
|
|
71
|
+
</ul>
|
|
72
|
+
<dl className="mt-6 space-y-2 border-t border-border pt-6 text-sm">
|
|
73
|
+
<Row label="Subtotal" value={formatMoney(order.subtotal, order.currency)} />
|
|
74
|
+
{order.shipping_total > 0 && <Row label={order.shipping_rate_name ?? "Shipping"} value={formatMoney(order.shipping_total, order.currency)} />}
|
|
75
|
+
{order.tax_total > 0 && <Row label="Tax" value={formatMoney(order.tax_total, order.currency)} />}
|
|
76
|
+
<div className="flex justify-between pt-2 text-base font-medium">
|
|
77
|
+
<dt>Total</dt>
|
|
78
|
+
<dd className="tabular-nums">{formatMoney(order.total, order.currency)}</dd>
|
|
79
|
+
</div>
|
|
80
|
+
</dl>
|
|
81
|
+
</section>
|
|
82
|
+
|
|
83
|
+
{address && (
|
|
84
|
+
<section className="mt-10 border-t border-border pt-8 text-sm">
|
|
85
|
+
<h2 className="font-medium">Shipping to</h2>
|
|
86
|
+
<address className="mt-2 not-italic leading-relaxed text-muted">
|
|
87
|
+
{[address.name, address.line1, address.line2, [address.postal_code, address.city].filter(Boolean).join(" "), address.country]
|
|
88
|
+
.filter(Boolean)
|
|
89
|
+
.map((line) => (
|
|
90
|
+
<span key={line} className="block">
|
|
91
|
+
{line}
|
|
92
|
+
</span>
|
|
93
|
+
))}
|
|
94
|
+
</address>
|
|
95
|
+
</section>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
<Link href="/" className="mt-12 inline-block text-sm underline underline-offset-4">
|
|
99
|
+
Continue shopping
|
|
100
|
+
</Link>
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function Row({ label, value }: { label: string; value: string }) {
|
|
106
|
+
return (
|
|
107
|
+
<div className="flex justify-between text-muted">
|
|
108
|
+
<dt>{label}</dt>
|
|
109
|
+
<dd className="tabular-nums">{value}</dd>
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function Centered({ children }: { children: React.ReactNode }) {
|
|
115
|
+
return <div className="mx-auto max-w-xl px-4 py-32 text-center">{children}</div>;
|
|
116
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { formatMoney } from "@kaching.sh/sdk";
|
|
2
|
+
|
|
3
|
+
export function Price({
|
|
4
|
+
amount,
|
|
5
|
+
currency,
|
|
6
|
+
compareAt,
|
|
7
|
+
className = "",
|
|
8
|
+
}: {
|
|
9
|
+
amount: number;
|
|
10
|
+
currency: string;
|
|
11
|
+
compareAt?: number | null;
|
|
12
|
+
className?: string;
|
|
13
|
+
}) {
|
|
14
|
+
const onSale = compareAt != null && compareAt > amount;
|
|
15
|
+
return (
|
|
16
|
+
<span className={`tabular-nums ${className}`}>
|
|
17
|
+
<span className={onSale ? "text-sale" : undefined}>{formatMoney(amount, currency)}</span>
|
|
18
|
+
{onSale && <s className="ml-2 text-muted">{formatMoney(compareAt, currency)}</s>}
|
|
19
|
+
</span>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Product } from "@kaching.sh/sdk";
|
|
2
|
+
import Image from "next/image";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { Price } from "./price";
|
|
5
|
+
|
|
6
|
+
export function ProductCard({ product, currency }: { product: Product; currency: string }) {
|
|
7
|
+
const image = product.images[0];
|
|
8
|
+
const soldOut = product.variants.every((v) => !v.available);
|
|
9
|
+
const range = product.price_range;
|
|
10
|
+
const first = product.variants[0];
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<Link href={`/products/${product.slug}`} className="group block">
|
|
14
|
+
<div className="relative aspect-[4/5] overflow-hidden rounded-card bg-surface">
|
|
15
|
+
{image ? (
|
|
16
|
+
<Image
|
|
17
|
+
src={image}
|
|
18
|
+
alt={product.title}
|
|
19
|
+
fill
|
|
20
|
+
sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw"
|
|
21
|
+
className="object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
|
22
|
+
/>
|
|
23
|
+
) : (
|
|
24
|
+
<div className="grid h-full place-items-center font-display text-6xl text-muted/60">{product.title[0]}</div>
|
|
25
|
+
)}
|
|
26
|
+
{soldOut && (
|
|
27
|
+
<span className="absolute left-3 top-3 rounded-full bg-background px-3 py-1 text-xs">Sold out</span>
|
|
28
|
+
)}
|
|
29
|
+
{product.type === "digital" && !soldOut && (
|
|
30
|
+
<span className="absolute left-3 top-3 rounded-full bg-background px-3 py-1 text-xs">Digital download</span>
|
|
31
|
+
)}
|
|
32
|
+
</div>
|
|
33
|
+
<div className="mt-4 flex items-baseline justify-between gap-4">
|
|
34
|
+
<h3 className="font-medium">{product.title}</h3>
|
|
35
|
+
{range && (
|
|
36
|
+
<p className="shrink-0 text-sm text-muted">
|
|
37
|
+
{range.min !== range.max && "From "}
|
|
38
|
+
<Price amount={range.min} currency={currency} compareAt={range.min === range.max ? first?.compare_at_price : null} />
|
|
39
|
+
</p>
|
|
40
|
+
)}
|
|
41
|
+
</div>
|
|
42
|
+
</Link>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Product, Variant } from "@kaching.sh/sdk";
|
|
4
|
+
import { useCart } from "@kaching.sh/react";
|
|
5
|
+
import { useMemo, useState } from "react";
|
|
6
|
+
import { Price } from "./price";
|
|
7
|
+
|
|
8
|
+
// Variant picker + add to cart. Variants with `options` (e.g. {size: "M", color: "Black"}) get one
|
|
9
|
+
// selector per option; otherwise variants are chosen by title.
|
|
10
|
+
export function ProductForm({ product, currency }: { product: Product; currency: string }) {
|
|
11
|
+
const { addItem, pending, error } = useCart();
|
|
12
|
+
const variants = product.variants;
|
|
13
|
+
|
|
14
|
+
const optionNames = useMemo(() => [...new Set(variants.flatMap((v) => Object.keys(v.options)))], [variants]);
|
|
15
|
+
const initial = variants.find((v) => v.available) ?? variants[0];
|
|
16
|
+
const [selected, setSelected] = useState<Variant>(initial);
|
|
17
|
+
|
|
18
|
+
const pickOption = (name: string, value: string) => {
|
|
19
|
+
const wanted = { ...selected.options, [name]: value };
|
|
20
|
+
const match =
|
|
21
|
+
variants.find((v) => optionNames.every((n) => v.options[n] === wanted[n])) ??
|
|
22
|
+
variants.find((v) => v.options[name] === value);
|
|
23
|
+
if (match) setSelected(match);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const [quantity, setQuantity] = useState(1);
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="space-y-8">
|
|
30
|
+
<Price amount={selected.price} currency={currency} compareAt={selected.compare_at_price} className="text-2xl" />
|
|
31
|
+
|
|
32
|
+
{variants.length > 1 &&
|
|
33
|
+
(optionNames.length > 0 ? (
|
|
34
|
+
optionNames.map((name) => {
|
|
35
|
+
const values = [...new Set(variants.map((v) => v.options[name]).filter(Boolean))];
|
|
36
|
+
return (
|
|
37
|
+
<Fieldset key={name} legend={name}>
|
|
38
|
+
{values.map((value) => {
|
|
39
|
+
const candidate = variants.find((v) => v.options[name] === value && optionNames.every((n) => n === name || v.options[n] === selected.options[n]));
|
|
40
|
+
return (
|
|
41
|
+
<Choice
|
|
42
|
+
key={value}
|
|
43
|
+
active={selected.options[name] === value}
|
|
44
|
+
unavailable={candidate ? !candidate.available : false}
|
|
45
|
+
onClick={() => pickOption(name, value)}
|
|
46
|
+
>
|
|
47
|
+
{value}
|
|
48
|
+
</Choice>
|
|
49
|
+
);
|
|
50
|
+
})}
|
|
51
|
+
</Fieldset>
|
|
52
|
+
);
|
|
53
|
+
})
|
|
54
|
+
) : (
|
|
55
|
+
<Fieldset legend="Option">
|
|
56
|
+
{variants.map((v) => (
|
|
57
|
+
<Choice key={v.id} active={v.id === selected.id} unavailable={!v.available} onClick={() => setSelected(v)}>
|
|
58
|
+
{v.title}
|
|
59
|
+
</Choice>
|
|
60
|
+
))}
|
|
61
|
+
</Fieldset>
|
|
62
|
+
))}
|
|
63
|
+
|
|
64
|
+
<div className="flex gap-3">
|
|
65
|
+
{product.type === "physical" && (
|
|
66
|
+
<div className="flex items-center rounded-full border border-border">
|
|
67
|
+
<button type="button" onClick={() => setQuantity((q) => Math.max(1, q - 1))} className="h-14 w-12 text-muted hover:text-foreground" aria-label="Decrease quantity">
|
|
68
|
+
−
|
|
69
|
+
</button>
|
|
70
|
+
<span className="w-6 text-center tabular-nums">{quantity}</span>
|
|
71
|
+
<button type="button" onClick={() => setQuantity((q) => Math.min(99, q + 1))} className="h-14 w-12 text-muted hover:text-foreground" aria-label="Increase quantity">
|
|
72
|
+
+
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
disabled={!selected.available || pending}
|
|
79
|
+
onClick={() => addItem(selected.id, quantity)}
|
|
80
|
+
className="h-14 flex-1 rounded-full bg-accent text-accent-foreground transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
81
|
+
>
|
|
82
|
+
{!selected.available ? "Sold out" : pending ? "Adding…" : "Add to cart"}
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
{error && <p className="text-sm text-sale">{error.message}</p>}
|
|
86
|
+
{product.type === "digital" && <p className="text-sm text-muted">Instant download after purchase. Links are also emailed to you.</p>}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function Fieldset({ legend, children }: { legend: string; children: React.ReactNode }) {
|
|
92
|
+
return (
|
|
93
|
+
<fieldset>
|
|
94
|
+
<legend className="mb-3 text-sm capitalize text-muted">{legend}</legend>
|
|
95
|
+
<div className="flex flex-wrap gap-2">{children}</div>
|
|
96
|
+
</fieldset>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function Choice({
|
|
101
|
+
active,
|
|
102
|
+
unavailable,
|
|
103
|
+
onClick,
|
|
104
|
+
children,
|
|
105
|
+
}: {
|
|
106
|
+
active: boolean;
|
|
107
|
+
unavailable: boolean;
|
|
108
|
+
onClick: () => void;
|
|
109
|
+
children: React.ReactNode;
|
|
110
|
+
}) {
|
|
111
|
+
return (
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
onClick={onClick}
|
|
115
|
+
aria-pressed={active}
|
|
116
|
+
className={`min-w-14 rounded-full border px-5 py-2.5 text-sm transition-colors ${
|
|
117
|
+
active ? "border-foreground bg-foreground text-background" : "border-border hover:border-foreground"
|
|
118
|
+
} ${unavailable ? "text-muted line-through" : ""}`}
|
|
119
|
+
>
|
|
120
|
+
{children}
|
|
121
|
+
</button>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { KachingProvider } from "@kaching.sh/react";
|
|
4
|
+
|
|
5
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<KachingProvider
|
|
8
|
+
publishableKey={process.env.NEXT_PUBLIC_KACHING_PUBLISHABLE_KEY!}
|
|
9
|
+
baseUrl={process.env.NEXT_PUBLIC_KACHING_API_URL}
|
|
10
|
+
>
|
|
11
|
+
{children}
|
|
12
|
+
</KachingProvider>
|
|
13
|
+
);
|
|
14
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { createKaching, KachingError } from "@kaching.sh/sdk";
|
|
2
|
+
import { notFound } from "next/navigation";
|
|
3
|
+
import { cache } from "react";
|
|
4
|
+
|
|
5
|
+
// Server-side client. The publishable key is safe here and in the browser; it can read the
|
|
6
|
+
// catalog and create carts/checkouts, nothing else.
|
|
7
|
+
export const kaching = createKaching({
|
|
8
|
+
apiKey: process.env.NEXT_PUBLIC_KACHING_PUBLISHABLE_KEY,
|
|
9
|
+
baseUrl: process.env.NEXT_PUBLIC_KACHING_API_URL,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
// Deduped per request, so layout + pages can both ask for the store.
|
|
13
|
+
export const getStore = cache(() => kaching.store.get());
|
|
14
|
+
|
|
15
|
+
export const getProducts = cache(async () => (await kaching.products.list({ limit: 100 })).data);
|
|
16
|
+
|
|
17
|
+
export const getProduct = cache(async (slug: string) => {
|
|
18
|
+
try {
|
|
19
|
+
return await kaching.products.get(slug);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if (err instanceof KachingError && err.status === 404) notFound();
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Storefront copy and layout knobs. Colors and fonts live in src/app/globals.css.
|
|
2
|
+
// The store name, currency and products come from kaching.
|
|
3
|
+
export const storeConfig = {
|
|
4
|
+
hero: {
|
|
5
|
+
eyebrow: "New collection",
|
|
6
|
+
title: "Made slowly, made well.",
|
|
7
|
+
subtitle: "Small-batch goods, shipped from our studio to your door.",
|
|
8
|
+
},
|
|
9
|
+
/** Thin bar above the header. Set to null to hide. */
|
|
10
|
+
announcement: "Free returns within 30 days" as string | null,
|
|
11
|
+
footer: {
|
|
12
|
+
blurb: "Independent studio. Every order packed by hand.",
|
|
13
|
+
},
|
|
14
|
+
/** Product grid columns on large screens: 3 or 4. */
|
|
15
|
+
gridColumns: 3 as 3 | 4,
|
|
16
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2017",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "react-jsx",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"plugins": [
|
|
17
|
+
{
|
|
18
|
+
"name": "next"
|
|
19
|
+
}
|
|
20
|
+
],
|
|
21
|
+
"paths": {
|
|
22
|
+
"@/*": ["./src/*"]
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"include": [
|
|
26
|
+
"next-env.d.ts",
|
|
27
|
+
"**/*.ts",
|
|
28
|
+
"**/*.tsx",
|
|
29
|
+
".next/types/**/*.ts",
|
|
30
|
+
".next/dev/types/**/*.ts",
|
|
31
|
+
"**/*.mts"
|
|
32
|
+
],
|
|
33
|
+
"exclude": ["node_modules"]
|
|
34
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../js/dist/index.js
|
|
4
|
+
var DEFAULT_BASE_URL = "https://kaching.sh/api/v1";
|
|
5
|
+
var KachingError = class extends Error {
|
|
6
|
+
constructor(status, code, message, details) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.details = details;
|
|
11
|
+
this.name = "KachingError";
|
|
12
|
+
}
|
|
13
|
+
status;
|
|
14
|
+
code;
|
|
15
|
+
details;
|
|
16
|
+
};
|
|
17
|
+
function createKaching(options = {}) {
|
|
18
|
+
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
19
|
+
const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
20
|
+
const token = options.apiKey ?? options.sessionToken;
|
|
21
|
+
async function request(method, path, body, query) {
|
|
22
|
+
const url = new URL(baseUrl + path);
|
|
23
|
+
for (const [k, v] of Object.entries(query ?? {})) if (v !== void 0) url.searchParams.set(k, String(v));
|
|
24
|
+
const isForm = typeof FormData !== "undefined" && body instanceof FormData;
|
|
25
|
+
const res = await doFetch(url, {
|
|
26
|
+
method,
|
|
27
|
+
headers: {
|
|
28
|
+
...token && { Authorization: `Bearer ${token}` },
|
|
29
|
+
...body !== void 0 && !isForm && { "Content-Type": "application/json" }
|
|
30
|
+
},
|
|
31
|
+
body: body === void 0 ? void 0 : isForm ? body : JSON.stringify(body),
|
|
32
|
+
cache: "no-store"
|
|
33
|
+
});
|
|
34
|
+
const data = await res.json().catch(() => null);
|
|
35
|
+
if (!res.ok) {
|
|
36
|
+
const err = data?.error;
|
|
37
|
+
throw new KachingError(res.status, err?.code ?? "http_error", err?.message ?? `Request failed (${res.status})`, err?.details);
|
|
38
|
+
}
|
|
39
|
+
return data;
|
|
40
|
+
}
|
|
41
|
+
const get = (path, query) => request("GET", path, void 0, query);
|
|
42
|
+
const post = (path, body) => request("POST", path, body ?? {});
|
|
43
|
+
const patch = (path, body) => request("PATCH", path, body);
|
|
44
|
+
const del = (path) => request("DELETE", path);
|
|
45
|
+
const enc = encodeURIComponent;
|
|
46
|
+
return {
|
|
47
|
+
stores: {
|
|
48
|
+
/** Requires a Clerk session token. */
|
|
49
|
+
list: () => get("/stores"),
|
|
50
|
+
/** Requires a Clerk session token. Returns API keys once. */
|
|
51
|
+
create: (input) => post("/stores", input)
|
|
52
|
+
},
|
|
53
|
+
store: {
|
|
54
|
+
get: () => get("/store"),
|
|
55
|
+
update: (input) => patch("/store", input)
|
|
56
|
+
},
|
|
57
|
+
payments: {
|
|
58
|
+
status: () => get("/store/payments"),
|
|
59
|
+
/** Creates the Stripe account if needed and returns onboarding links. */
|
|
60
|
+
onboard: (input = {}) => post("/store/payments", input)
|
|
61
|
+
},
|
|
62
|
+
products: {
|
|
63
|
+
list: (query = {}) => get("/products", query),
|
|
64
|
+
/** By id or slug. */
|
|
65
|
+
get: (idOrSlug) => get(`/products/${enc(idOrSlug)}`),
|
|
66
|
+
create: (input) => post("/products", input),
|
|
67
|
+
update: (idOrSlug, input) => patch(`/products/${enc(idOrSlug)}`, input),
|
|
68
|
+
delete: (idOrSlug) => del(`/products/${enc(idOrSlug)}`),
|
|
69
|
+
addVariant: (idOrSlug, input) => post(`/products/${enc(idOrSlug)}/variants`, input),
|
|
70
|
+
/** Uploads an image (≤4 MB) and appends it to the product. */
|
|
71
|
+
uploadImage: (idOrSlug, file, filename = "image") => {
|
|
72
|
+
const form = new FormData();
|
|
73
|
+
form.append("file", file, filename);
|
|
74
|
+
return request("POST", `/products/${enc(idOrSlug)}/images`, form);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
variants: {
|
|
78
|
+
update: (id, input) => patch(`/variants/${enc(id)}`, input),
|
|
79
|
+
delete: (id) => del(`/variants/${enc(id)}`),
|
|
80
|
+
files: {
|
|
81
|
+
list: (variantId) => get(`/variants/${enc(variantId)}/files`),
|
|
82
|
+
/** Registers a file and uploads its bytes via the signed URL. */
|
|
83
|
+
upload: async (variantId, file, filename) => {
|
|
84
|
+
const created = await post(`/variants/${enc(variantId)}/files`, {
|
|
85
|
+
filename,
|
|
86
|
+
content_type: file.type || void 0,
|
|
87
|
+
size_bytes: file.size
|
|
88
|
+
});
|
|
89
|
+
const res = await doFetch(created.upload_url, {
|
|
90
|
+
method: "PUT",
|
|
91
|
+
headers: file.type ? { "Content-Type": file.type } : {},
|
|
92
|
+
body: file
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok) throw new KachingError(res.status, "upload_failed", `File upload failed (${res.status})`);
|
|
95
|
+
const { upload_url: _, ...rest } = created;
|
|
96
|
+
return rest;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
shippingRates: {
|
|
101
|
+
list: () => get("/shipping-rates"),
|
|
102
|
+
create: (input) => post("/shipping-rates", input),
|
|
103
|
+
update: (id, input) => patch(`/shipping-rates/${enc(id)}`, input),
|
|
104
|
+
delete: (id) => del(`/shipping-rates/${enc(id)}`)
|
|
105
|
+
},
|
|
106
|
+
carts: {
|
|
107
|
+
create: (input = {}) => post("/carts", input),
|
|
108
|
+
get: (id) => get(`/carts/${enc(id)}`),
|
|
109
|
+
/** Adds to the existing quantity. */
|
|
110
|
+
addItem: (id, item) => post(`/carts/${enc(id)}/items`, item),
|
|
111
|
+
/** Sets the quantity; 0 removes the line. */
|
|
112
|
+
updateItem: (id, variantId, quantity) => patch(`/carts/${enc(id)}/items/${enc(variantId)}`, { quantity }),
|
|
113
|
+
removeItem: (id, variantId) => del(`/carts/${enc(id)}/items/${enc(variantId)}`),
|
|
114
|
+
checkout: (id, input) => post(`/carts/${enc(id)}/checkout`, input)
|
|
115
|
+
},
|
|
116
|
+
checkout: {
|
|
117
|
+
/** "Buy now": creates a cart and a checkout in one call. */
|
|
118
|
+
create: (input) => post("/checkout", input),
|
|
119
|
+
/** The order for a completed Checkout Session. 404 until the payment webhook lands — poll briefly. */
|
|
120
|
+
getOrder: (sessionId) => get(`/checkout/sessions/${enc(sessionId)}`)
|
|
121
|
+
},
|
|
122
|
+
orders: {
|
|
123
|
+
list: (query = {}) => get("/orders", query),
|
|
124
|
+
get: (id) => get(`/orders/${enc(id)}`),
|
|
125
|
+
fulfill: (id) => post(`/orders/${enc(id)}/fulfill`)
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function formatMoney(amount, currency, locale) {
|
|
130
|
+
const code = currency.toUpperCase();
|
|
131
|
+
const digits = new Intl.NumberFormat("en", { style: "currency", currency: code }).resolvedOptions().maximumFractionDigits ?? 2;
|
|
132
|
+
return new Intl.NumberFormat(locale, { style: "currency", currency: code }).format(amount / 10 ** digits);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/output.ts
|
|
136
|
+
import { spawn } from "child_process";
|
|
137
|
+
var CliError = class extends Error {
|
|
138
|
+
constructor(message, code = "error") {
|
|
139
|
+
super(message);
|
|
140
|
+
this.code = code;
|
|
141
|
+
}
|
|
142
|
+
code;
|
|
143
|
+
};
|
|
144
|
+
var jsonMode = false;
|
|
145
|
+
var setJsonMode = (on) => {
|
|
146
|
+
jsonMode = on;
|
|
147
|
+
};
|
|
148
|
+
var isJson = () => jsonMode;
|
|
149
|
+
var tty = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
150
|
+
var wrap = (code) => (s) => tty ? `\x1B[${code}m${s}\x1B[0m` : s;
|
|
151
|
+
var c = { bold: wrap(1), dim: wrap(2), green: wrap(32), yellow: wrap(33), red: wrap(31), cyan: wrap(36) };
|
|
152
|
+
function out(data, human) {
|
|
153
|
+
if (jsonMode) console.log(JSON.stringify(data, null, 2));
|
|
154
|
+
else human(data);
|
|
155
|
+
}
|
|
156
|
+
function log(message) {
|
|
157
|
+
if (!jsonMode) console.error(message);
|
|
158
|
+
}
|
|
159
|
+
function table(rows) {
|
|
160
|
+
if (rows.length === 0) return;
|
|
161
|
+
const cols = Object.keys(rows[0]);
|
|
162
|
+
const width = (col) => Math.max(col.length, ...rows.map((r) => String(r[col]).length));
|
|
163
|
+
const widths = Object.fromEntries(cols.map((col) => [col, width(col)]));
|
|
164
|
+
console.log(c.dim(cols.map((col) => col.toUpperCase().padEnd(widths[col])).join(" ")));
|
|
165
|
+
for (const r of rows) console.log(cols.map((col) => String(r[col]).padEnd(widths[col])).join(" "));
|
|
166
|
+
}
|
|
167
|
+
function handleError(err) {
|
|
168
|
+
const e = err instanceof KachingError ? { code: err.code, message: err.message, status: err.status, details: err.details } : err instanceof CliError ? { code: err.code, message: err.message } : { code: "error", message: err instanceof Error ? err.message : String(err) };
|
|
169
|
+
if (jsonMode) console.log(JSON.stringify({ error: e }, null, 2));
|
|
170
|
+
else {
|
|
171
|
+
console.error(`${c.red("\u2716")} ${e.message}`);
|
|
172
|
+
if ("details" in e && e.details) console.error(c.dim(JSON.stringify(e.details)));
|
|
173
|
+
}
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
function openBrowser(url) {
|
|
177
|
+
if (!process.stdout.isTTY || process.env.CI) return false;
|
|
178
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
179
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
180
|
+
try {
|
|
181
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
182
|
+
return true;
|
|
183
|
+
} catch {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function toMinor(value, currency) {
|
|
188
|
+
const n = Number(value);
|
|
189
|
+
if (!Number.isFinite(n) || n < 0) throw new CliError(`Invalid price: ${value}`, "invalid_price");
|
|
190
|
+
const digits = new Intl.NumberFormat("en", { style: "currency", currency: currency.toUpperCase() }).resolvedOptions().maximumFractionDigits ?? 2;
|
|
191
|
+
return Math.round(n * 10 ** digits);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// src/config.ts
|
|
195
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
196
|
+
import { homedir } from "os";
|
|
197
|
+
import { dirname, join } from "path";
|
|
198
|
+
var configPath = () => join(process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"), "kaching", "config.json");
|
|
199
|
+
function readConfig() {
|
|
200
|
+
try {
|
|
201
|
+
return JSON.parse(readFileSync(configPath(), "utf8"));
|
|
202
|
+
} catch {
|
|
203
|
+
return {};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function writeConfig(update) {
|
|
207
|
+
const path = configPath();
|
|
208
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
209
|
+
const next = { ...readConfig(), ...update };
|
|
210
|
+
for (const k of Object.keys(next)) if (next[k] === void 0) delete next[k];
|
|
211
|
+
writeFileSync(path, JSON.stringify(next, null, 2) + "\n", { mode: 384 });
|
|
212
|
+
chmodSync(path, 384);
|
|
213
|
+
}
|
|
214
|
+
function parseEnvFile(path) {
|
|
215
|
+
if (!existsSync(path)) return {};
|
|
216
|
+
const out2 = {};
|
|
217
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
218
|
+
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
|
219
|
+
if (m) out2[m[1]] = m[2].replace(/^["']|["']$/g, "");
|
|
220
|
+
}
|
|
221
|
+
return out2;
|
|
222
|
+
}
|
|
223
|
+
function upsertEnvFile(path, values) {
|
|
224
|
+
let text = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
225
|
+
for (const [key, value] of Object.entries(values)) {
|
|
226
|
+
if (value === void 0) continue;
|
|
227
|
+
const line = `${key}=${value}`;
|
|
228
|
+
const re = new RegExp(`^${key}=.*$`, "m");
|
|
229
|
+
text = re.test(text) ? text.replace(re, line) : `${text}${text && !text.endsWith("\n") ? "\n" : ""}${line}
|
|
230
|
+
`;
|
|
231
|
+
}
|
|
232
|
+
writeFileSync(path, text, { mode: 384 });
|
|
233
|
+
}
|
|
234
|
+
function projectEnv() {
|
|
235
|
+
let dir = process.cwd();
|
|
236
|
+
for (; ; ) {
|
|
237
|
+
const env = { ...parseEnvFile(join(dir, ".env")), ...parseEnvFile(join(dir, ".env.local")) };
|
|
238
|
+
if (env.KACHING_SECRET_KEY || env.NEXT_PUBLIC_KACHING_PUBLISHABLE_KEY) return env;
|
|
239
|
+
const parent = dirname(dir);
|
|
240
|
+
if (parent === dir) return {};
|
|
241
|
+
dir = parent;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function apiUrl(override) {
|
|
245
|
+
return (override ?? process.env.KACHING_API_URL ?? projectEnv().NEXT_PUBLIC_KACHING_API_URL ?? readConfig().api_url ?? DEFAULT_BASE_URL).replace(/\/$/, "");
|
|
246
|
+
}
|
|
247
|
+
function accountClient() {
|
|
248
|
+
const token = process.env.KACHING_ACCOUNT_TOKEN ?? readConfig().account_token;
|
|
249
|
+
if (!token) throw new CliError("Not logged in. Run `kaching login` first.", "not_logged_in");
|
|
250
|
+
return createKaching({ apiKey: token, baseUrl: apiUrl() });
|
|
251
|
+
}
|
|
252
|
+
function storeClient() {
|
|
253
|
+
const key = process.env.KACHING_SECRET_KEY ?? projectEnv().KACHING_SECRET_KEY;
|
|
254
|
+
if (!key) {
|
|
255
|
+
throw new CliError(
|
|
256
|
+
"No store linked to this folder. Run `kaching create` to make one, or `kaching link` to connect an existing store.",
|
|
257
|
+
"no_store"
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return createKaching({ apiKey: key, baseUrl: apiUrl() });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export {
|
|
264
|
+
DEFAULT_BASE_URL,
|
|
265
|
+
KachingError,
|
|
266
|
+
formatMoney,
|
|
267
|
+
CliError,
|
|
268
|
+
setJsonMode,
|
|
269
|
+
isJson,
|
|
270
|
+
c,
|
|
271
|
+
out,
|
|
272
|
+
log,
|
|
273
|
+
table,
|
|
274
|
+
handleError,
|
|
275
|
+
openBrowser,
|
|
276
|
+
toMinor,
|
|
277
|
+
readConfig,
|
|
278
|
+
writeConfig,
|
|
279
|
+
upsertEnvFile,
|
|
280
|
+
apiUrl,
|
|
281
|
+
accountClient,
|
|
282
|
+
storeClient
|
|
283
|
+
};
|