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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kaching
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# kaching CLI
|
|
2
|
+
|
|
3
|
+
Build a real online store from your terminal — or let your coding agent do it.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx kaching-cli login
|
|
7
|
+
npx kaching-cli create my-store --currency usd
|
|
8
|
+
cd my-store && npm run dev
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
| Command | What it does |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `kaching login` | Approve this machine in the browser (saves an account token) |
|
|
14
|
+
| `kaching create [dir]` | New store + Next.js storefront, keys in `.env.local`, deps installed |
|
|
15
|
+
| `kaching link [store]` | Connect an existing folder to a store you own |
|
|
16
|
+
| `kaching products create --title … --price 25` | Add products (`--variant "M:25:10"`, `--image`, `--type digital --file`) |
|
|
17
|
+
| `kaching products import products.json` | Bulk import (API format, prices in minor units) |
|
|
18
|
+
| `kaching shipping create --name Standard --price 5 --countries US,CA` | Shipping rates |
|
|
19
|
+
| `kaching payments connect` | Link for the owner to finish Stripe onboarding (the one human step) |
|
|
20
|
+
| `kaching orders list` / `orders fulfill <id>` | Orders |
|
|
21
|
+
|
|
22
|
+
Every command takes `--json` for machine-readable output. Prices on the command line are in normal
|
|
23
|
+
units (`24.90`); the API itself uses minor units (`2490`).
|
package/assets/sdk.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":"0.1.0"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# kaching storefront
|
|
2
|
+
|
|
3
|
+
A Next.js store wired to [kaching](https://kaching.sh): products, cart, Stripe checkout, orders and
|
|
4
|
+
digital downloads are handled by the kaching API. This repo is only the storefront — you own every
|
|
5
|
+
line of UI here, so change anything.
|
|
6
|
+
|
|
7
|
+
## Where things live
|
|
8
|
+
|
|
9
|
+
| To change… | Edit |
|
|
10
|
+
| ---------------------------------- | ----------------------------------------------------------- |
|
|
11
|
+
| Colors, radius | `src/app/globals.css` (`:root` tokens) |
|
|
12
|
+
| Fonts | `src/app/layout.tsx` (`body` / `heading` next/font imports) |
|
|
13
|
+
| Hero text, announcement bar, footer| `src/store.config.ts` |
|
|
14
|
+
| Home page layout | `src/app/page.tsx` |
|
|
15
|
+
| Product page | `src/app/products/[slug]/page.tsx`, `src/components/product-form.tsx` |
|
|
16
|
+
| Cart drawer | `src/components/cart-drawer.tsx` |
|
|
17
|
+
| Order confirmation | `src/components/order-confirmation.tsx` |
|
|
18
|
+
|
|
19
|
+
Components use semantic Tailwind colors only (`bg-background`, `text-muted`, `border-border`,
|
|
20
|
+
`bg-accent`, `text-sale`, `rounded-card`, `font-display`). Keep it that way so the theme stays in one place.
|
|
21
|
+
|
|
22
|
+
## Data
|
|
23
|
+
|
|
24
|
+
- **Server components** read the catalog with `src/lib/kaching.ts` (`getStore`, `getProducts`, `getProduct`).
|
|
25
|
+
- **Client components** use `@kaching.sh/react`: `useCart()` (cart + `addItem`/`updateItem`/`removeItem`/`checkout`,
|
|
26
|
+
drawer `isOpen`/`open`/`close`) and `useOrder(sessionId)` on the success page.
|
|
27
|
+
- Money is always an integer in minor units. Format with `formatMoney(amount, currency)`.
|
|
28
|
+
- Store name, currency and products come from kaching — don't hardcode them. Change products with the
|
|
29
|
+
kaching CLI/MCP (or `@kaching.sh/sdk` with the secret key), not in this repo.
|
|
30
|
+
|
|
31
|
+
## Rules
|
|
32
|
+
|
|
33
|
+
- Only the **publishable** key (`kc_pk_...`) belongs here. Never put a secret key (`kc_sk_...`) in this repo
|
|
34
|
+
or in any `NEXT_PUBLIC_` variable.
|
|
35
|
+
- Don't mark orders paid or fulfil anything from the success page — kaching does that from Stripe webhooks.
|
|
36
|
+
- Checkout returns to `/checkout/success?session_id=...`. Keep that route (or pass `successPath` to
|
|
37
|
+
`KachingProvider` in `src/components/providers.tsx`).
|
|
38
|
+
|
|
39
|
+
## Env
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
NEXT_PUBLIC_KACHING_PUBLISHABLE_KEY=kc_pk_...
|
|
43
|
+
NEXT_PUBLIC_KACHING_API_URL= # optional, defaults to https://kaching.sh/api/v1
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
<!-- BEGIN:nextjs-agent-rules -->
|
|
47
|
+
|
|
48
|
+
# This is NOT the Next.js you know
|
|
49
|
+
|
|
50
|
+
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
|
|
51
|
+
|
|
52
|
+
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
|
|
53
|
+
|
|
54
|
+
<!-- END:nextjs-agent-rules -->
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Storefront
|
|
2
|
+
|
|
3
|
+
Built with [kaching](https://kaching.sh) — commerce for AI agents.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
cp .env.example .env.local # add your publishable key
|
|
7
|
+
pnpm install
|
|
8
|
+
pnpm dev # http://localhost:3001
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Customize the look in `src/app/globals.css` and `src/store.config.ts`. See `AGENTS.md` for the map.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineConfig, globalIgnores } from "eslint/config";
|
|
2
|
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
|
3
|
+
import nextTs from "eslint-config-next/typescript";
|
|
4
|
+
|
|
5
|
+
const eslintConfig = defineConfig([
|
|
6
|
+
...nextVitals,
|
|
7
|
+
...nextTs,
|
|
8
|
+
// Override default ignores of eslint-config-next.
|
|
9
|
+
globalIgnores([
|
|
10
|
+
// Default ignores of eslint-config-next:
|
|
11
|
+
".next/**",
|
|
12
|
+
"out/**",
|
|
13
|
+
"build/**",
|
|
14
|
+
"next-env.d.ts",
|
|
15
|
+
]),
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
export default eslintConfig;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
|
2
|
+
|
|
3
|
+
# dependencies
|
|
4
|
+
/node_modules
|
|
5
|
+
/.pnp
|
|
6
|
+
.pnp.*
|
|
7
|
+
.yarn/*
|
|
8
|
+
!.yarn/patches
|
|
9
|
+
!.yarn/plugins
|
|
10
|
+
!.yarn/releases
|
|
11
|
+
!.yarn/versions
|
|
12
|
+
|
|
13
|
+
# testing
|
|
14
|
+
/coverage
|
|
15
|
+
|
|
16
|
+
# next.js
|
|
17
|
+
/.next/
|
|
18
|
+
/out/
|
|
19
|
+
|
|
20
|
+
# production
|
|
21
|
+
/build
|
|
22
|
+
|
|
23
|
+
# misc
|
|
24
|
+
.DS_Store
|
|
25
|
+
*.pem
|
|
26
|
+
|
|
27
|
+
# debug
|
|
28
|
+
npm-debug.log*
|
|
29
|
+
yarn-debug.log*
|
|
30
|
+
yarn-error.log*
|
|
31
|
+
.pnpm-debug.log*
|
|
32
|
+
|
|
33
|
+
# env files (can opt-in for committing if needed)
|
|
34
|
+
.env*
|
|
35
|
+
!.env.example
|
|
36
|
+
|
|
37
|
+
# vercel
|
|
38
|
+
.vercel
|
|
39
|
+
|
|
40
|
+
# typescript
|
|
41
|
+
*.tsbuildinfo
|
|
42
|
+
next-env.d.ts
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { NextConfig } from "next";
|
|
2
|
+
|
|
3
|
+
const nextConfig: NextConfig = {
|
|
4
|
+
images: {
|
|
5
|
+
// Product images are hosted by kaching (Supabase Storage) or wherever the merchant points them.
|
|
6
|
+
remotePatterns: [{ protocol: "https", hostname: "**" }],
|
|
7
|
+
},
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export default nextConfig;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kaching/storefront-template",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev -p 3001",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start -p 3001",
|
|
9
|
+
"lint": "eslint",
|
|
10
|
+
"typecheck": "tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"next": "16.3.6",
|
|
14
|
+
"react": "19.2.8",
|
|
15
|
+
"react-dom": "19.2.8",
|
|
16
|
+
"@kaching.sh/react": "workspace:*",
|
|
17
|
+
"@kaching.sh/sdk": "workspace:*"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@tailwindcss/postcss": "^4",
|
|
21
|
+
"@types/node": "^20",
|
|
22
|
+
"@types/react": "^19",
|
|
23
|
+
"@types/react-dom": "^19",
|
|
24
|
+
"eslint": "^9",
|
|
25
|
+
"eslint-config-next": "16.3.6",
|
|
26
|
+
"tailwindcss": "^4",
|
|
27
|
+
"typescript": "^5"
|
|
28
|
+
},
|
|
29
|
+
"packageManager": "pnpm@11.18.0"
|
|
30
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { OrderConfirmation } from "@/components/order-confirmation";
|
|
2
|
+
|
|
3
|
+
export const metadata = { title: "Thank you" };
|
|
4
|
+
|
|
5
|
+
// Stripe redirects here with ?session_id=cs_... after payment.
|
|
6
|
+
export default async function SuccessPage({ searchParams }: PageProps<"/checkout/success">) {
|
|
7
|
+
const { session_id } = await searchParams;
|
|
8
|
+
return <OrderConfirmation sessionId={typeof session_id === "string" ? session_id : null} />;
|
|
9
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Theme tokens — rebrand the whole store by editing these.
|
|
5
|
+
Components only use these semantic colors (bg-background, text-muted, border-border, ...).
|
|
6
|
+
*/
|
|
7
|
+
:root {
|
|
8
|
+
--background: #f7f4ef;
|
|
9
|
+
--foreground: #1f1b16;
|
|
10
|
+
--muted: #776e64;
|
|
11
|
+
--surface: #ffffff;
|
|
12
|
+
--border: #e4ddd3;
|
|
13
|
+
--accent: #1f1b16;
|
|
14
|
+
--accent-foreground: #f7f4ef;
|
|
15
|
+
--sale: #b4442c;
|
|
16
|
+
--radius: 14px;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@theme inline {
|
|
20
|
+
--color-background: var(--background);
|
|
21
|
+
--color-foreground: var(--foreground);
|
|
22
|
+
--color-muted: var(--muted);
|
|
23
|
+
--color-surface: var(--surface);
|
|
24
|
+
--color-border: var(--border);
|
|
25
|
+
--color-accent: var(--accent);
|
|
26
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
27
|
+
--color-sale: var(--sale);
|
|
28
|
+
--radius-card: var(--radius);
|
|
29
|
+
--font-sans: var(--font-body);
|
|
30
|
+
--font-display: var(--font-heading);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
body {
|
|
34
|
+
background: var(--background);
|
|
35
|
+
color: var(--foreground);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
::selection {
|
|
39
|
+
background: var(--accent);
|
|
40
|
+
color: var(--accent-foreground);
|
|
41
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import { Geist, Instrument_Serif } from "next/font/google";
|
|
3
|
+
import { CartDrawer } from "@/components/cart-drawer";
|
|
4
|
+
import { Header } from "@/components/header";
|
|
5
|
+
import { Providers } from "@/components/providers";
|
|
6
|
+
import { getStore } from "@/lib/kaching";
|
|
7
|
+
import { storeConfig } from "@/store.config";
|
|
8
|
+
import "./globals.css";
|
|
9
|
+
|
|
10
|
+
// Fonts: swap these to rebrand. `--font-heading` is used by `font-display`.
|
|
11
|
+
const body = Geist({ variable: "--font-body", subsets: ["latin"] });
|
|
12
|
+
const heading = Instrument_Serif({ variable: "--font-heading", weight: "400", subsets: ["latin"] });
|
|
13
|
+
|
|
14
|
+
export async function generateMetadata(): Promise<Metadata> {
|
|
15
|
+
const store = await getStore();
|
|
16
|
+
return {
|
|
17
|
+
title: { default: store.name, template: `%s · ${store.name}` },
|
|
18
|
+
description: storeConfig.hero.subtitle,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default async function RootLayout({ children }: LayoutProps<"/">) {
|
|
23
|
+
const store = await getStore();
|
|
24
|
+
return (
|
|
25
|
+
<html lang="en" className={`${body.variable} ${heading.variable} antialiased`}>
|
|
26
|
+
<body className="flex min-h-dvh flex-col font-sans">
|
|
27
|
+
<Providers>
|
|
28
|
+
<Header storeName={store.name} />
|
|
29
|
+
<main className="flex-1">{children}</main>
|
|
30
|
+
<footer className="mt-24 border-t border-border">
|
|
31
|
+
<div className="mx-auto flex max-w-6xl flex-col gap-2 px-4 py-10 text-sm text-muted sm:flex-row sm:justify-between sm:px-6">
|
|
32
|
+
<p>
|
|
33
|
+
© {new Date().getFullYear()} {store.name}. {storeConfig.footer.blurb}
|
|
34
|
+
</p>
|
|
35
|
+
{store.support_email && (
|
|
36
|
+
<a href={`mailto:${store.support_email}`} className="hover:text-foreground">
|
|
37
|
+
{store.support_email}
|
|
38
|
+
</a>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
</footer>
|
|
42
|
+
<CartDrawer />
|
|
43
|
+
</Providers>
|
|
44
|
+
</body>
|
|
45
|
+
</html>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
|
|
3
|
+
export default function NotFound() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="mx-auto max-w-xl px-4 py-32 text-center">
|
|
6
|
+
<h1 className="font-display text-5xl">Not found</h1>
|
|
7
|
+
<p className="mt-4 text-muted">That page or product doesn't exist.</p>
|
|
8
|
+
<Link href="/" className="mt-8 inline-block underline underline-offset-4">
|
|
9
|
+
Back to the shop
|
|
10
|
+
</Link>
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ProductCard } from "@/components/product-card";
|
|
2
|
+
import { getProducts, getStore } from "@/lib/kaching";
|
|
3
|
+
import { storeConfig } from "@/store.config";
|
|
4
|
+
|
|
5
|
+
export default async function HomePage() {
|
|
6
|
+
const [store, products] = await Promise.all([getStore(), getProducts()]);
|
|
7
|
+
const { hero } = storeConfig;
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<>
|
|
11
|
+
<section className="mx-auto max-w-6xl px-4 pb-16 pt-20 sm:px-6 sm:pt-28">
|
|
12
|
+
<p className="text-sm uppercase tracking-[0.2em] text-muted">{hero.eyebrow}</p>
|
|
13
|
+
<h1 className="mt-5 max-w-3xl font-display text-5xl leading-[1.05] tracking-tight sm:text-7xl">{hero.title}</h1>
|
|
14
|
+
<p className="mt-6 max-w-xl text-lg text-muted">{hero.subtitle}</p>
|
|
15
|
+
</section>
|
|
16
|
+
|
|
17
|
+
<section id="shop" className="mx-auto max-w-6xl scroll-mt-24 px-4 sm:px-6">
|
|
18
|
+
{products.length === 0 ? (
|
|
19
|
+
<div className="rounded-card border border-dashed border-border px-6 py-20 text-center text-muted">
|
|
20
|
+
No products yet. Add some with <code className="text-foreground">kaching products create</code> and they'll
|
|
21
|
+
appear here.
|
|
22
|
+
</div>
|
|
23
|
+
) : (
|
|
24
|
+
<div
|
|
25
|
+
className={`grid grid-cols-1 gap-x-6 gap-y-12 sm:grid-cols-2 ${
|
|
26
|
+
storeConfig.gridColumns === 4 ? "lg:grid-cols-4" : "lg:grid-cols-3"
|
|
27
|
+
}`}
|
|
28
|
+
>
|
|
29
|
+
{products.map((product) => (
|
|
30
|
+
<ProductCard key={product.id} product={product} currency={store.currency} />
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
33
|
+
)}
|
|
34
|
+
</section>
|
|
35
|
+
</>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
import Image from "next/image";
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { ProductForm } from "@/components/product-form";
|
|
5
|
+
import { getProduct, getStore } from "@/lib/kaching";
|
|
6
|
+
|
|
7
|
+
export async function generateMetadata({ params }: PageProps<"/products/[slug]">): Promise<Metadata> {
|
|
8
|
+
const { slug } = await params;
|
|
9
|
+
const product = await getProduct(slug);
|
|
10
|
+
return {
|
|
11
|
+
title: product.title,
|
|
12
|
+
description: product.description ?? undefined,
|
|
13
|
+
openGraph: { images: product.images.slice(0, 1) },
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default async function ProductPage({ params }: PageProps<"/products/[slug]">) {
|
|
18
|
+
const { slug } = await params;
|
|
19
|
+
const [store, product] = await Promise.all([getStore(), getProduct(slug)]);
|
|
20
|
+
const [cover, ...rest] = product.images;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="mx-auto max-w-6xl px-4 pt-8 sm:px-6">
|
|
24
|
+
<Link href="/" className="text-sm text-muted hover:text-foreground">
|
|
25
|
+
← All products
|
|
26
|
+
</Link>
|
|
27
|
+
|
|
28
|
+
<div className="mt-6 grid gap-10 lg:grid-cols-[1.1fr_1fr] lg:gap-16">
|
|
29
|
+
<div className="space-y-4">
|
|
30
|
+
<div className="relative aspect-[4/5] overflow-hidden rounded-card bg-surface">
|
|
31
|
+
{cover ? (
|
|
32
|
+
<Image src={cover} alt={product.title} fill priority sizes="(min-width: 1024px) 55vw, 100vw" className="object-cover" />
|
|
33
|
+
) : (
|
|
34
|
+
<div className="grid h-full place-items-center font-display text-8xl text-muted/60">{product.title[0]}</div>
|
|
35
|
+
)}
|
|
36
|
+
</div>
|
|
37
|
+
{rest.length > 0 && (
|
|
38
|
+
<div className="grid grid-cols-2 gap-4">
|
|
39
|
+
{rest.map((src) => (
|
|
40
|
+
<div key={src} className="relative aspect-square overflow-hidden rounded-card bg-surface">
|
|
41
|
+
<Image src={src} alt="" fill sizes="(min-width: 1024px) 27vw, 50vw" className="object-cover" />
|
|
42
|
+
</div>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div className="lg:sticky lg:top-28 lg:self-start">
|
|
49
|
+
<h1 className="font-display text-4xl leading-tight sm:text-5xl">{product.title}</h1>
|
|
50
|
+
<div className="mt-6">
|
|
51
|
+
<ProductForm product={product} currency={store.currency} />
|
|
52
|
+
</div>
|
|
53
|
+
{product.description && (
|
|
54
|
+
<div className="mt-10 whitespace-pre-line border-t border-border pt-8 leading-relaxed text-muted">
|
|
55
|
+
{product.description}
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCart } from "@kaching.sh/react";
|
|
4
|
+
|
|
5
|
+
export function CartButton() {
|
|
6
|
+
const { cart, open } = useCart();
|
|
7
|
+
const count = cart?.item_count ?? 0;
|
|
8
|
+
return (
|
|
9
|
+
<button
|
|
10
|
+
type="button"
|
|
11
|
+
onClick={open}
|
|
12
|
+
className="flex items-center gap-2 rounded-full border border-border px-4 py-2 text-sm transition-colors hover:border-foreground"
|
|
13
|
+
aria-label={`Open cart, ${count} item${count === 1 ? "" : "s"}`}
|
|
14
|
+
>
|
|
15
|
+
Cart
|
|
16
|
+
<span className="grid h-5 min-w-5 place-items-center rounded-full bg-accent px-1.5 text-xs text-accent-foreground tabular-nums">
|
|
17
|
+
{count}
|
|
18
|
+
</span>
|
|
19
|
+
</button>
|
|
20
|
+
);
|
|
21
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { formatMoney, useCart } from "@kaching.sh/react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { useEffect } from "react";
|
|
6
|
+
|
|
7
|
+
export function CartDrawer() {
|
|
8
|
+
const { cart, isOpen, close, updateItem, removeItem, checkout, pending, error } = useCart();
|
|
9
|
+
const items = cart?.items ?? [];
|
|
10
|
+
|
|
11
|
+
// Close on Escape, lock page scroll while open.
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (!isOpen) return;
|
|
14
|
+
const onKey = (e: KeyboardEvent) => e.key === "Escape" && close();
|
|
15
|
+
document.addEventListener("keydown", onKey);
|
|
16
|
+
const overflow = document.body.style.overflow;
|
|
17
|
+
document.body.style.overflow = "hidden";
|
|
18
|
+
return () => {
|
|
19
|
+
document.removeEventListener("keydown", onKey);
|
|
20
|
+
document.body.style.overflow = overflow;
|
|
21
|
+
};
|
|
22
|
+
}, [isOpen, close]);
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className={`fixed inset-0 z-50 ${isOpen ? "" : "pointer-events-none"}`} inert={!isOpen}>
|
|
26
|
+
<div
|
|
27
|
+
onClick={close}
|
|
28
|
+
className={`absolute inset-0 bg-foreground/30 transition-opacity duration-300 ${isOpen ? "opacity-100" : "opacity-0"}`}
|
|
29
|
+
/>
|
|
30
|
+
<aside
|
|
31
|
+
role="dialog"
|
|
32
|
+
aria-label="Shopping cart"
|
|
33
|
+
className={`absolute right-0 top-0 flex h-full w-full max-w-md flex-col bg-background transition-transform duration-300 ease-out ${
|
|
34
|
+
isOpen ? "translate-x-0 shadow-2xl" : "translate-x-full"
|
|
35
|
+
}`}
|
|
36
|
+
>
|
|
37
|
+
<div className="flex items-center justify-between border-b border-border px-6 py-5">
|
|
38
|
+
<h2 className="font-display text-2xl">Your cart</h2>
|
|
39
|
+
<button type="button" onClick={close} className="text-sm text-muted hover:text-foreground">
|
|
40
|
+
Close
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
{items.length === 0 ? (
|
|
45
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-6 text-center">
|
|
46
|
+
<p className="text-muted">Your cart is empty.</p>
|
|
47
|
+
<button type="button" onClick={close} className="text-sm underline underline-offset-4">
|
|
48
|
+
Keep shopping
|
|
49
|
+
</button>
|
|
50
|
+
</div>
|
|
51
|
+
) : (
|
|
52
|
+
<>
|
|
53
|
+
<ul className="flex-1 divide-y divide-border overflow-y-auto px-6">
|
|
54
|
+
{items.map((item) => (
|
|
55
|
+
<li key={item.id} className="flex gap-4 py-5">
|
|
56
|
+
<Link
|
|
57
|
+
href={`/products/${item.product_slug}`}
|
|
58
|
+
onClick={close}
|
|
59
|
+
className="h-24 w-20 shrink-0 overflow-hidden rounded-[calc(var(--radius)*0.6)] bg-surface"
|
|
60
|
+
>
|
|
61
|
+
{item.image ? (
|
|
62
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
63
|
+
<img src={item.image} alt="" className="h-full w-full object-cover" />
|
|
64
|
+
) : (
|
|
65
|
+
<div className="grid h-full place-items-center font-display text-2xl text-muted">{item.title[0]}</div>
|
|
66
|
+
)}
|
|
67
|
+
</Link>
|
|
68
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
69
|
+
<div className="flex justify-between gap-3">
|
|
70
|
+
<div className="min-w-0">
|
|
71
|
+
<p className="truncate font-medium">{item.title}</p>
|
|
72
|
+
{item.variant_title && <p className="text-sm text-muted">{item.variant_title}</p>}
|
|
73
|
+
{!item.available && <p className="text-sm text-sale">No longer available in this quantity</p>}
|
|
74
|
+
</div>
|
|
75
|
+
<p className="shrink-0 tabular-nums">{formatMoney(item.line_total, cart!.currency)}</p>
|
|
76
|
+
</div>
|
|
77
|
+
<div className="mt-auto flex items-center justify-between pt-3">
|
|
78
|
+
<div className="flex items-center rounded-full border border-border">
|
|
79
|
+
<button
|
|
80
|
+
type="button"
|
|
81
|
+
disabled={pending}
|
|
82
|
+
onClick={() => updateItem(item.variant_id, item.quantity - 1)}
|
|
83
|
+
className="h-8 w-8 text-muted hover:text-foreground disabled:opacity-40"
|
|
84
|
+
aria-label="Decrease quantity"
|
|
85
|
+
>
|
|
86
|
+
−
|
|
87
|
+
</button>
|
|
88
|
+
<span className="w-6 text-center text-sm tabular-nums">{item.quantity}</span>
|
|
89
|
+
<button
|
|
90
|
+
type="button"
|
|
91
|
+
disabled={pending}
|
|
92
|
+
onClick={() => updateItem(item.variant_id, item.quantity + 1)}
|
|
93
|
+
className="h-8 w-8 text-muted hover:text-foreground disabled:opacity-40"
|
|
94
|
+
aria-label="Increase quantity"
|
|
95
|
+
>
|
|
96
|
+
+
|
|
97
|
+
</button>
|
|
98
|
+
</div>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
disabled={pending}
|
|
102
|
+
onClick={() => removeItem(item.variant_id)}
|
|
103
|
+
className="text-sm text-muted underline-offset-4 hover:text-foreground hover:underline"
|
|
104
|
+
>
|
|
105
|
+
Remove
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
</li>
|
|
110
|
+
))}
|
|
111
|
+
</ul>
|
|
112
|
+
|
|
113
|
+
<div className="space-y-4 border-t border-border px-6 py-6">
|
|
114
|
+
{error && <p className="text-sm text-sale">{error.message}</p>}
|
|
115
|
+
<div className="flex justify-between text-lg">
|
|
116
|
+
<span>Subtotal</span>
|
|
117
|
+
<span className="tabular-nums">{formatMoney(cart!.subtotal, cart!.currency)}</span>
|
|
118
|
+
</div>
|
|
119
|
+
<p className="text-sm text-muted">
|
|
120
|
+
{cart!.requires_shipping ? "Shipping and taxes calculated at checkout." : "Taxes calculated at checkout."}
|
|
121
|
+
</p>
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
disabled={pending}
|
|
125
|
+
onClick={() => checkout()}
|
|
126
|
+
className="w-full rounded-full bg-accent py-4 text-accent-foreground transition-opacity hover:opacity-90 disabled:opacity-60"
|
|
127
|
+
>
|
|
128
|
+
{pending ? "One moment…" : "Checkout"}
|
|
129
|
+
</button>
|
|
130
|
+
</div>
|
|
131
|
+
</>
|
|
132
|
+
)}
|
|
133
|
+
</aside>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { storeConfig } from "@/store.config";
|
|
3
|
+
import { CartButton } from "./cart-button";
|
|
4
|
+
|
|
5
|
+
export function Header({ storeName }: { storeName: string }) {
|
|
6
|
+
return (
|
|
7
|
+
<header className="sticky top-0 z-30 border-b border-border bg-background/85 backdrop-blur">
|
|
8
|
+
{storeConfig.announcement && (
|
|
9
|
+
<p className="bg-accent px-4 py-1.5 text-center text-xs tracking-wide text-accent-foreground">
|
|
10
|
+
{storeConfig.announcement}
|
|
11
|
+
</p>
|
|
12
|
+
)}
|
|
13
|
+
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-4 sm:px-6">
|
|
14
|
+
<Link href="/" className="font-display text-2xl leading-none tracking-tight">
|
|
15
|
+
{storeName}
|
|
16
|
+
</Link>
|
|
17
|
+
<nav className="flex items-center gap-6 text-sm">
|
|
18
|
+
<Link href="/#shop" className="hidden text-muted transition-colors hover:text-foreground sm:block">
|
|
19
|
+
Shop
|
|
20
|
+
</Link>
|
|
21
|
+
<CartButton />
|
|
22
|
+
</nav>
|
|
23
|
+
</div>
|
|
24
|
+
</header>
|
|
25
|
+
);
|
|
26
|
+
}
|