create-getly-store 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/README.md ADDED
@@ -0,0 +1,59 @@
1
+ # create-getly-store
2
+
3
+ Scaffold a minimal, beautiful Next.js storefront for your
4
+ [Getly](https://www.getly.store) store — one command, zero dependencies in the
5
+ scaffolder itself:
6
+
7
+ ```bash
8
+ npx create-getly-store my-store --store your-store-slug
9
+ cd my-store
10
+ npm install
11
+ npm run dev # http://localhost:3000
12
+ ```
13
+
14
+ Omit `--store` and you'll be prompted. The slug is the part after
15
+ `getly.store/store/` on your public store page.
16
+
17
+ ## What you get
18
+
19
+ A complete Next.js 15 App Router app (React 19, TypeScript, strict) that
20
+ renders your store's active products from Getly's **public** storefront API —
21
+ no API key, no database:
22
+
23
+ - **Catalog grid** (`app/page.tsx`) — `GET /api/v1/public/stores/{slug}/products`,
24
+ server-fetched with `revalidate: 300`.
25
+ - **Product pages** (`app/p/[slug]/page.tsx`) — single-product endpoint with
26
+ description, gallery and metadata.
27
+ - **Buy button** → Getly's hosted checkout (`urls.buy`); files are delivered
28
+ by Getly after payment.
29
+ - **Money done right** — the template reads only `priceCents` (integer cents)
30
+ and formats with `Intl.NumberFormat`.
31
+ - **Dark/light** via `prefers-color-scheme`; styling is tasteful hand-written
32
+ CSS (`app/globals.css`), no component libraries.
33
+
34
+ ## Configuration
35
+
36
+ The store slug is written into two places at scaffold time:
37
+
38
+ - `getly.config.json` — the committed default;
39
+ - `.env.local.example` — copy to `.env.local` (or set on your host) as
40
+ `NEXT_PUBLIC_GETLY_STORE_SLUG` to override without code changes.
41
+
42
+ ## Deploy
43
+
44
+ The generated `README.md` documents the honest path: push the folder to a
45
+ GitHub repository, then import it at <https://vercel.com/new> (a `vercel.json`
46
+ is included) — or add Vercel's Deploy button to *your* repo pointing at
47
+ itself. A one-click clone URL can only point at a repo you control, so the
48
+ scaffolder doesn't pretend otherwise.
49
+
50
+ ## Safety notes
51
+
52
+ - The scaffolder **refuses to write into a non-empty directory** — it never
53
+ overwrites existing files.
54
+ - The template never injects seller-provided HTML (descriptions are stripped
55
+ to plain paragraphs — no `dangerouslySetInnerHTML`).
56
+
57
+ ## License
58
+
59
+ MIT
package/dist/cli.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ interface Args {
3
+ dir?: string;
4
+ store?: string;
5
+ help: boolean;
6
+ errors: string[];
7
+ }
8
+ export declare function parseArgs(argv: string[]): Args;
9
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * create-getly-store — npx scaffolder. Zero runtime dependencies.
4
+ *
5
+ * npx create-getly-store my-store --store my-getly-slug
6
+ */
7
+ import path from 'node:path';
8
+ import readline from 'node:readline/promises';
9
+ import { normalizeSlug, scaffold, ScaffoldError } from './index.js';
10
+ const HELP = `create-getly-store — a minimal Next.js storefront for your Getly store.
11
+
12
+ Usage:
13
+ npx create-getly-store <directory> [--store <store-slug>]
14
+
15
+ Options:
16
+ --store <slug> Your Getly store slug (the part after getly.store/store/).
17
+ Prompted for interactively when omitted.
18
+ --help, -h Show this help.
19
+ `;
20
+ export function parseArgs(argv) {
21
+ const args = { help: false, errors: [] };
22
+ for (let i = 0; i < argv.length; i++) {
23
+ const arg = argv[i];
24
+ if (arg === '--help' || arg === '-h')
25
+ args.help = true;
26
+ else if (arg === '--store') {
27
+ const value = argv[++i];
28
+ if (!value)
29
+ args.errors.push('--store requires a value');
30
+ else
31
+ args.store = value;
32
+ }
33
+ else if (arg.startsWith('-')) {
34
+ args.errors.push(`Unknown flag: ${arg}`);
35
+ }
36
+ else if (args.dir) {
37
+ args.errors.push(`Unexpected extra argument: ${arg}`);
38
+ }
39
+ else {
40
+ args.dir = arg;
41
+ }
42
+ }
43
+ return args;
44
+ }
45
+ async function main() {
46
+ const args = parseArgs(process.argv.slice(2));
47
+ if (args.help || (!args.dir && args.errors.length === 0)) {
48
+ console.log(HELP);
49
+ return args.help ? 0 : 1;
50
+ }
51
+ if (args.errors.length > 0) {
52
+ for (const err of args.errors)
53
+ console.error(`Error: ${err}`);
54
+ console.error('\nRun with --help for usage.');
55
+ return 1;
56
+ }
57
+ let storeSlug = args.store;
58
+ if (!storeSlug) {
59
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
60
+ try {
61
+ const answer = await rl.question('Your Getly store slug (getly.store/store/<slug>): ');
62
+ storeSlug = answer;
63
+ }
64
+ finally {
65
+ rl.close();
66
+ }
67
+ }
68
+ if (!normalizeSlug(storeSlug ?? '')) {
69
+ console.error('Error: a store slug is required (letters, digits and dashes).');
70
+ console.error('Find yours on your store page URL: https://www.getly.store/dashboard/settings');
71
+ return 1;
72
+ }
73
+ try {
74
+ const result = scaffold({ targetDir: args.dir, storeSlug: storeSlug });
75
+ const rel = path.relative(process.cwd(), result.targetDir) || '.';
76
+ console.log(`\nScaffolded ${result.filesWritten.length} files into ${rel}/`);
77
+ console.log(`Store slug: ${result.storeSlug} (written to getly.config.json and .env.local.example)`);
78
+ console.log('\nNext steps:');
79
+ console.log(` cd ${rel}`);
80
+ console.log(' npm install');
81
+ console.log(' npm run dev # http://localhost:3000');
82
+ console.log('\nDeploy:');
83
+ console.log(' push this folder to a GitHub repo, then import it at https://vercel.com/new');
84
+ console.log(' (details + a Deploy button recipe are in the generated README.md)');
85
+ return 0;
86
+ }
87
+ catch (err) {
88
+ if (err instanceof ScaffoldError) {
89
+ console.error(`Error: ${err.message}`);
90
+ return 1;
91
+ }
92
+ throw err;
93
+ }
94
+ }
95
+ main().then((code) => process.exit(code), (err) => {
96
+ console.error(err);
97
+ process.exit(1);
98
+ });
@@ -0,0 +1,25 @@
1
+ export declare class ScaffoldError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ export interface ScaffoldOptions {
5
+ targetDir: string;
6
+ storeSlug: string;
7
+ /** Defaults to the template shipped with this package. */
8
+ templateDir?: string;
9
+ /** Defaults to the target directory's basename. */
10
+ appName?: string;
11
+ }
12
+ export interface ScaffoldResult {
13
+ targetDir: string;
14
+ storeSlug: string;
15
+ appName: string;
16
+ filesWritten: string[];
17
+ }
18
+ /** Lowercases and strips anything a Getly store slug can't contain. */
19
+ export declare function normalizeSlug(raw: string): string;
20
+ export declare function defaultTemplateDir(): string;
21
+ /**
22
+ * Scaffold the storefront. Refuses to write into an existing non-empty
23
+ * directory (no-clobber guard) — an existing EMPTY directory is fine.
24
+ */
25
+ export declare function scaffold(opts: ScaffoldOptions): ScaffoldResult;
package/dist/index.js ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * create-getly-store — scaffold logic (zero runtime dependencies, node:fs only).
3
+ *
4
+ * Copies the embedded `template/` directory into the target, injecting the
5
+ * store slug into `getly.config.json` and `.env.local.example`. Files that
6
+ * npm would mangle inside a published package are shipped under safe names
7
+ * and renamed on copy (`gitignore` → `.gitignore`, `env.local.example` →
8
+ * `.env.local.example`).
9
+ */
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+ export class ScaffoldError extends Error {
14
+ constructor(message) {
15
+ super(message);
16
+ this.name = 'ScaffoldError';
17
+ }
18
+ }
19
+ /** npm publish mangles some dotfiles — ship safe names, rename on copy. */
20
+ const RENAMES = {
21
+ gitignore: '.gitignore',
22
+ 'env.local.example': '.env.local.example',
23
+ };
24
+ const SLUG_TOKEN = '__STORE_SLUG__';
25
+ const APP_NAME_TOKEN = '__APP_NAME__';
26
+ /** Lowercases and strips anything a Getly store slug can't contain. */
27
+ export function normalizeSlug(raw) {
28
+ return raw
29
+ .trim()
30
+ .toLowerCase()
31
+ .replace(/[^a-z0-9-]+/g, '-')
32
+ .replace(/-{2,}/g, '-')
33
+ .replace(/^-+|-+$/g, '');
34
+ }
35
+ export function defaultTemplateDir() {
36
+ // src/index.ts → ../template ; dist/index.js → ../template (same depth).
37
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'template');
38
+ }
39
+ function sanitizeAppName(raw) {
40
+ const name = normalizeSlug(raw);
41
+ return name || 'getly-storefront';
42
+ }
43
+ function walkTemplate(dir, base = '') {
44
+ const out = [];
45
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
46
+ const rel = base ? path.join(base, entry.name) : entry.name;
47
+ if (entry.isDirectory()) {
48
+ // Never copy build artifacts if someone ran the template in place.
49
+ if (entry.name === 'node_modules' || entry.name === '.next')
50
+ continue;
51
+ out.push(...walkTemplate(path.join(dir, entry.name), rel));
52
+ }
53
+ else if (entry.isFile()) {
54
+ out.push(rel);
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+ /**
60
+ * Scaffold the storefront. Refuses to write into an existing non-empty
61
+ * directory (no-clobber guard) — an existing EMPTY directory is fine.
62
+ */
63
+ export function scaffold(opts) {
64
+ const storeSlug = normalizeSlug(opts.storeSlug);
65
+ if (!storeSlug) {
66
+ throw new ScaffoldError(`"${opts.storeSlug}" is not a usable store slug (letters, digits and dashes only).`);
67
+ }
68
+ const targetDir = path.resolve(opts.targetDir);
69
+ const templateDir = opts.templateDir ?? defaultTemplateDir();
70
+ if (!fs.existsSync(templateDir)) {
71
+ throw new ScaffoldError(`Template directory not found: ${templateDir}`);
72
+ }
73
+ if (fs.existsSync(targetDir)) {
74
+ const stat = fs.statSync(targetDir);
75
+ if (!stat.isDirectory()) {
76
+ throw new ScaffoldError(`Target exists and is not a directory: ${targetDir}`);
77
+ }
78
+ if (fs.readdirSync(targetDir).length > 0) {
79
+ throw new ScaffoldError(`Target directory is not empty: ${targetDir} — refusing to overwrite existing files.`);
80
+ }
81
+ }
82
+ const appName = sanitizeAppName(opts.appName ?? path.basename(targetDir));
83
+ const filesWritten = [];
84
+ for (const rel of walkTemplate(templateDir)) {
85
+ const parts = rel.split(path.sep);
86
+ const fileName = parts[parts.length - 1];
87
+ const renamed = RENAMES[fileName] ?? fileName;
88
+ const destRel = path.join(...parts.slice(0, -1), renamed);
89
+ const destAbs = path.join(targetDir, destRel);
90
+ fs.mkdirSync(path.dirname(destAbs), { recursive: true });
91
+ const content = fs
92
+ .readFileSync(path.join(templateDir, rel), 'utf8')
93
+ .replaceAll(SLUG_TOKEN, storeSlug)
94
+ .replaceAll(APP_NAME_TOKEN, appName);
95
+ fs.writeFileSync(destAbs, content);
96
+ filesWritten.push(destRel);
97
+ }
98
+ return { targetDir, storeSlug, appName, filesWritten };
99
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "create-getly-store",
3
+ "version": "0.1.0",
4
+ "description": "Scaffold a minimal, beautiful Next.js storefront powered by the Getly public API.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Getly-Store/headless-sdk.git",
10
+ "directory": "packages/create-getly-store"
11
+ },
12
+ "homepage": "https://www.getly.store/developers",
13
+ "keywords": ["getly", "create", "storefront", "nextjs", "digital-products"],
14
+ "bin": {
15
+ "create-getly-store": "./dist/cli.js"
16
+ },
17
+ "main": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ }
24
+ },
25
+ "files": ["dist", "template", "README.md"],
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "scripts": {
30
+ "build": "tsc -p tsconfig.build.json",
31
+ "typecheck": "tsc --noEmit",
32
+ "test": "vitest run",
33
+ "prepublishOnly": "npm run build"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^20.11.0"
37
+ }
38
+ }
@@ -0,0 +1,75 @@
1
+ # __APP_NAME__
2
+
3
+ A minimal Next.js storefront for the Getly store **`__STORE_SLUG__`**,
4
+ scaffolded with [`create-getly-store`](https://www.npmjs.com/package/create-getly-store).
5
+
6
+ It renders your store's **active products** from Getly's public API — no API
7
+ key, no database, no server secrets:
8
+
9
+ - `GET /api/v1/public/stores/__STORE_SLUG__/products` — catalog grid (`app/page.tsx`)
10
+ - `GET /api/v1/public/stores/__STORE_SLUG__/products/{slug}` — product page (`app/p/[slug]/page.tsx`)
11
+
12
+ Pages are server-rendered with `revalidate: 300` (5 minutes — the same cache
13
+ the API itself serves), all prices are integer cents formatted with `Intl`,
14
+ and the **Buy** button sends visitors to Getly's hosted checkout (`urls.buy`).
15
+ Dark/light theme follows the visitor's system preference. Styling is plain
16
+ hand-written CSS in `app/globals.css` — no component libraries.
17
+
18
+ ## Run locally
19
+
20
+ ```bash
21
+ npm install
22
+ npm run dev # http://localhost:3000
23
+ ```
24
+
25
+ ## Point it at a different store
26
+
27
+ The store slug lives in `getly.config.json`. An env var overrides it without
28
+ touching code — copy `.env.local.example` to `.env.local` and edit:
29
+
30
+ ```bash
31
+ NEXT_PUBLIC_GETLY_STORE_SLUG=your-store-slug
32
+ ```
33
+
34
+ Your slug is the part after `getly.store/store/` on your public store page.
35
+
36
+ ## Deploy
37
+
38
+ This folder is a complete, standalone Next.js app (a `vercel.json` is
39
+ included). Vercel's one-click **Deploy** button needs a Git repository URL to
40
+ clone, so the honest recipe is:
41
+
42
+ 1. Push this folder to a GitHub repository:
43
+
44
+ ```bash
45
+ git init && git add -A && git commit -m "storefront"
46
+ git remote add origin https://github.com/YOU/YOUR-REPO.git
47
+ git push -u origin main
48
+ ```
49
+
50
+ 2. Import it at <https://vercel.com/new> (framework is auto-detected), **or**
51
+ put a Deploy button in your repo's README pointing at itself:
52
+
53
+ ```md
54
+ [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/YOU/YOUR-REPO)
55
+ ```
56
+
57
+ 3. Optionally set `NEXT_PUBLIC_GETLY_STORE_SLUG` in the Vercel project's
58
+ environment variables to switch stores per deployment.
59
+
60
+ Any other Node 18+ host works too: `npm run build && npm start`.
61
+
62
+ ## Files
63
+
64
+ ```
65
+ app/
66
+ layout.tsx Shell + footer + metadata
67
+ page.tsx Product grid (public catalog endpoint)
68
+ p/[slug]/page.tsx Product detail + Buy button
69
+ globals.css All styling (hand CSS, dark/light aware)
70
+ lib/
71
+ config.ts Store slug + API origin resolution
72
+ getly.ts Typed public-API helpers (cents → Intl formatting)
73
+ getly.config.json The store this storefront renders
74
+ vercel.json Framework hint for Vercel
75
+ ```
@@ -0,0 +1,332 @@
1
+ /* Minimal hand-rolled storefront styles — no component libraries.
2
+ Light/dark follows the visitor's system preference. */
3
+
4
+ :root {
5
+ --bg: #faf9f7;
6
+ --surface: #ffffff;
7
+ --text: #1c1b1a;
8
+ --text-muted: #6d6a64;
9
+ --border: #e7e4de;
10
+ --accent: #1c1b1a;
11
+ --accent-contrast: #ffffff;
12
+ --shadow: 0 1px 2px rgba(28, 27, 26, 0.06), 0 8px 24px rgba(28, 27, 26, 0.06);
13
+ --radius: 14px;
14
+ }
15
+
16
+ @media (prefers-color-scheme: dark) {
17
+ :root {
18
+ --bg: #131211;
19
+ --surface: #1c1b1a;
20
+ --text: #f2f0ec;
21
+ --text-muted: #a09c94;
22
+ --border: #2c2a27;
23
+ --accent: #f2f0ec;
24
+ --accent-contrast: #131211;
25
+ --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
26
+ }
27
+ }
28
+
29
+ * {
30
+ box-sizing: border-box;
31
+ margin: 0;
32
+ padding: 0;
33
+ }
34
+
35
+ html {
36
+ -webkit-text-size-adjust: 100%;
37
+ }
38
+
39
+ body {
40
+ background: var(--bg);
41
+ color: var(--text);
42
+ font-family:
43
+ ui-sans-serif,
44
+ system-ui,
45
+ -apple-system,
46
+ "Segoe UI",
47
+ Roboto,
48
+ "Helvetica Neue",
49
+ sans-serif;
50
+ font-size: 16px;
51
+ line-height: 1.6;
52
+ -webkit-font-smoothing: antialiased;
53
+ }
54
+
55
+ img {
56
+ max-width: 100%;
57
+ display: block;
58
+ }
59
+
60
+ a {
61
+ color: inherit;
62
+ }
63
+
64
+ code {
65
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
66
+ font-size: 0.9em;
67
+ background: var(--surface);
68
+ border: 1px solid var(--border);
69
+ border-radius: 6px;
70
+ padding: 0.1em 0.35em;
71
+ }
72
+
73
+ .page {
74
+ min-height: 100dvh;
75
+ display: flex;
76
+ flex-direction: column;
77
+ }
78
+
79
+ .container {
80
+ width: 100%;
81
+ max-width: 1080px;
82
+ margin: 0 auto;
83
+ padding: 3rem 1.25rem 4rem;
84
+ flex: 1;
85
+ }
86
+
87
+ /* ---------- Hero ---------- */
88
+
89
+ .hero {
90
+ margin-bottom: 3rem;
91
+ max-width: 40rem;
92
+ }
93
+
94
+ .eyebrow {
95
+ text-transform: uppercase;
96
+ letter-spacing: 0.14em;
97
+ font-size: 0.75rem;
98
+ font-weight: 600;
99
+ color: var(--text-muted);
100
+ margin-bottom: 0.5rem;
101
+ }
102
+
103
+ h1 {
104
+ font-size: clamp(2rem, 5vw, 3rem);
105
+ line-height: 1.1;
106
+ letter-spacing: -0.02em;
107
+ font-weight: 700;
108
+ }
109
+
110
+ .hero .muted {
111
+ margin-top: 0.75rem;
112
+ }
113
+
114
+ .muted {
115
+ color: var(--text-muted);
116
+ }
117
+
118
+ .small {
119
+ font-size: 0.85rem;
120
+ }
121
+
122
+ /* ---------- Product grid ---------- */
123
+
124
+ .grid {
125
+ list-style: none;
126
+ display: grid;
127
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
128
+ gap: 1.5rem;
129
+ }
130
+
131
+ .card {
132
+ background: var(--surface);
133
+ border: 1px solid var(--border);
134
+ border-radius: var(--radius);
135
+ overflow: hidden;
136
+ display: flex;
137
+ flex-direction: column;
138
+ transition:
139
+ transform 160ms ease,
140
+ box-shadow 160ms ease;
141
+ }
142
+
143
+ .card:hover {
144
+ transform: translateY(-3px);
145
+ box-shadow: var(--shadow);
146
+ }
147
+
148
+ .card-media-link {
149
+ display: block;
150
+ }
151
+
152
+ .card-media {
153
+ width: 100%;
154
+ aspect-ratio: 4 / 3;
155
+ object-fit: cover;
156
+ background: var(--border);
157
+ }
158
+
159
+ .card-media-empty {
160
+ display: grid;
161
+ place-items: center;
162
+ font-size: 2.5rem;
163
+ font-weight: 700;
164
+ color: var(--text-muted);
165
+ }
166
+
167
+ .card-body {
168
+ padding: 1rem 1.1rem 1.25rem;
169
+ display: flex;
170
+ flex-direction: column;
171
+ gap: 0.5rem;
172
+ flex: 1;
173
+ }
174
+
175
+ .card-title {
176
+ font-size: 1.05rem;
177
+ font-weight: 650;
178
+ line-height: 1.3;
179
+ letter-spacing: -0.01em;
180
+ }
181
+
182
+ .card-title a {
183
+ text-decoration: none;
184
+ }
185
+
186
+ .card-title a:hover {
187
+ text-decoration: underline;
188
+ }
189
+
190
+ .card-desc {
191
+ font-size: 0.9rem;
192
+ color: var(--text-muted);
193
+ display: -webkit-box;
194
+ -webkit-line-clamp: 2;
195
+ -webkit-box-orient: vertical;
196
+ overflow: hidden;
197
+ }
198
+
199
+ .card-footer {
200
+ display: flex;
201
+ align-items: baseline;
202
+ justify-content: space-between;
203
+ gap: 0.5rem;
204
+ margin-top: auto;
205
+ padding-top: 0.25rem;
206
+ }
207
+
208
+ .price {
209
+ font-weight: 700;
210
+ font-variant-numeric: tabular-nums;
211
+ }
212
+
213
+ .price-lg {
214
+ font-size: 1.5rem;
215
+ }
216
+
217
+ .rating {
218
+ font-size: 0.85rem;
219
+ color: var(--text-muted);
220
+ }
221
+
222
+ /* ---------- Buttons ---------- */
223
+
224
+ .button {
225
+ display: inline-block;
226
+ text-align: center;
227
+ text-decoration: none;
228
+ background: var(--accent);
229
+ color: var(--accent-contrast);
230
+ font-weight: 600;
231
+ font-size: 0.95rem;
232
+ border-radius: 999px;
233
+ padding: 0.6rem 1.4rem;
234
+ transition: opacity 120ms ease;
235
+ }
236
+
237
+ .button:hover {
238
+ opacity: 0.85;
239
+ }
240
+
241
+ .button-lg {
242
+ padding: 0.85rem 2rem;
243
+ font-size: 1.05rem;
244
+ margin-top: 0.5rem;
245
+ }
246
+
247
+ /* ---------- Product page ---------- */
248
+
249
+ .breadcrumb {
250
+ margin-bottom: 2rem;
251
+ font-size: 0.9rem;
252
+ }
253
+
254
+ .breadcrumb a {
255
+ color: var(--text-muted);
256
+ text-decoration: none;
257
+ }
258
+
259
+ .breadcrumb a:hover {
260
+ color: var(--text);
261
+ }
262
+
263
+ .product {
264
+ display: grid;
265
+ grid-template-columns: 1fr;
266
+ gap: 2.5rem;
267
+ }
268
+
269
+ @media (min-width: 820px) {
270
+ .product {
271
+ grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
272
+ align-items: start;
273
+ }
274
+ }
275
+
276
+ .product-media {
277
+ display: flex;
278
+ flex-direction: column;
279
+ gap: 1rem;
280
+ }
281
+
282
+ .product-image {
283
+ width: 100%;
284
+ border-radius: var(--radius);
285
+ border: 1px solid var(--border);
286
+ background: var(--surface);
287
+ }
288
+
289
+ .product-info {
290
+ display: flex;
291
+ flex-direction: column;
292
+ gap: 1rem;
293
+ }
294
+
295
+ .product-meta {
296
+ display: flex;
297
+ align-items: baseline;
298
+ gap: 1rem;
299
+ }
300
+
301
+ .lead {
302
+ font-size: 1.1rem;
303
+ color: var(--text-muted);
304
+ }
305
+
306
+ .description {
307
+ margin-top: 1.5rem;
308
+ border-top: 1px solid var(--border);
309
+ padding-top: 1.5rem;
310
+ display: flex;
311
+ flex-direction: column;
312
+ gap: 0.85rem;
313
+ }
314
+
315
+ .description h2 {
316
+ font-size: 1.15rem;
317
+ letter-spacing: -0.01em;
318
+ }
319
+
320
+ /* ---------- Footer ---------- */
321
+
322
+ .footer {
323
+ border-top: 1px solid var(--border);
324
+ padding: 1.5rem 1.25rem;
325
+ text-align: center;
326
+ font-size: 0.85rem;
327
+ color: var(--text-muted);
328
+ }
329
+
330
+ .footer a {
331
+ color: inherit;
332
+ }
@@ -0,0 +1,39 @@
1
+ import type { Metadata } from "next";
2
+ import { STORE_SLUG } from "@/lib/config";
3
+ import "./globals.css";
4
+
5
+ export const metadata: Metadata = {
6
+ title: {
7
+ default: `${STORE_SLUG} — storefront`,
8
+ template: `%s — ${STORE_SLUG}`,
9
+ },
10
+ description: `Digital products by ${STORE_SLUG}, powered by Getly.`,
11
+ };
12
+
13
+ export default function RootLayout({
14
+ children,
15
+ }: {
16
+ children: React.ReactNode;
17
+ }) {
18
+ return (
19
+ <html lang="en">
20
+ <body>
21
+ <div className="page">
22
+ {children}
23
+ <footer className="footer">
24
+ <p>
25
+ Powered by{" "}
26
+ <a
27
+ href="https://www.getly.store"
28
+ target="_blank"
29
+ rel="noopener noreferrer"
30
+ >
31
+ Getly
32
+ </a>
33
+ </p>
34
+ </footer>
35
+ </div>
36
+ </body>
37
+ </html>
38
+ );
39
+ }
@@ -0,0 +1,99 @@
1
+ import type { Metadata } from "next";
2
+ import Link from "next/link";
3
+ import { notFound } from "next/navigation";
4
+ import { STORE_SLUG } from "@/lib/config";
5
+ import {
6
+ descriptionToParagraphs,
7
+ formatPriceCents,
8
+ formatRating,
9
+ getProduct,
10
+ } from "@/lib/getly";
11
+
12
+ /** Re-fetch the product at most every 5 minutes (matches the API's cache). */
13
+ export const revalidate = 300;
14
+
15
+ interface PageProps {
16
+ params: Promise<{ slug: string }>;
17
+ }
18
+
19
+ export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
20
+ const { slug } = await params;
21
+ const product = await getProduct(STORE_SLUG, slug);
22
+ if (!product) return {};
23
+ return {
24
+ title: product.name,
25
+ description: product.shortDescription ?? undefined,
26
+ openGraph: product.images[0] ? { images: [product.images[0].url] } : undefined,
27
+ };
28
+ }
29
+
30
+ export default async function ProductPage({ params }: PageProps) {
31
+ const { slug } = await params;
32
+ const product = await getProduct(STORE_SLUG, slug);
33
+ if (!product) notFound();
34
+
35
+ const rating = formatRating(product.avgRating, product.reviewCount);
36
+ const paragraphs = descriptionToParagraphs(product.description);
37
+
38
+ return (
39
+ <main className="container">
40
+ <nav className="breadcrumb">
41
+ <Link href="/">&larr; All products</Link>
42
+ </nav>
43
+
44
+ <article className="product">
45
+ <div className="product-media">
46
+ {product.images.length > 0 ? (
47
+ product.images.map((image, i) => (
48
+ <img
49
+ key={image.url}
50
+ className="product-image"
51
+ src={image.url}
52
+ alt={image.altText ?? product.name}
53
+ loading={i === 0 ? "eager" : "lazy"}
54
+ />
55
+ ))
56
+ ) : (
57
+ <span className="card-media card-media-empty product-image" aria-hidden>
58
+ {product.name.slice(0, 1).toUpperCase()}
59
+ </span>
60
+ )}
61
+ </div>
62
+
63
+ <div className="product-info">
64
+ <h1>{product.name}</h1>
65
+ <div className="product-meta">
66
+ <span className="price price-lg">
67
+ {formatPriceCents(product.priceCents, product.currency)}
68
+ </span>
69
+ {rating && <span className="rating">{rating}</span>}
70
+ </div>
71
+ {product.shortDescription && (
72
+ <p className="lead">{product.shortDescription}</p>
73
+ )}
74
+ <a
75
+ className="button button-lg"
76
+ href={product.urls.buy}
77
+ target="_blank"
78
+ rel="noopener noreferrer"
79
+ >
80
+ Buy now — instant download
81
+ </a>
82
+ <p className="muted small">
83
+ Secure checkout on Getly. Files are delivered instantly after
84
+ payment.
85
+ </p>
86
+
87
+ {paragraphs.length > 0 && (
88
+ <section className="description">
89
+ <h2>About this product</h2>
90
+ {paragraphs.map((text, i) => (
91
+ <p key={i}>{text}</p>
92
+ ))}
93
+ </section>
94
+ )}
95
+ </div>
96
+ </article>
97
+ </main>
98
+ );
99
+ }
@@ -0,0 +1,103 @@
1
+ import type { Metadata } from "next";
2
+ import Link from "next/link";
3
+ import { STORE_SLUG } from "@/lib/config";
4
+ import {
5
+ formatPriceCents,
6
+ formatRating,
7
+ getStoreProducts,
8
+ } from "@/lib/getly";
9
+
10
+ /** Re-fetch the catalog at most every 5 minutes (matches the API's cache). */
11
+ export const revalidate = 300;
12
+
13
+ export async function generateMetadata(): Promise<Metadata> {
14
+ const data = await getStoreProducts(STORE_SLUG);
15
+ if (!data) return {};
16
+ return {
17
+ title: data.store.name,
18
+ description: `Digital products by ${data.store.name} — powered by Getly.`,
19
+ };
20
+ }
21
+
22
+ export default async function HomePage() {
23
+ const data = await getStoreProducts(STORE_SLUG);
24
+
25
+ if (!data) {
26
+ return (
27
+ <main className="container">
28
+ <header className="hero">
29
+ <h1>Store not found</h1>
30
+ <p className="muted">
31
+ No public store answers to the slug &ldquo;{STORE_SLUG}&rdquo;.
32
+ Check <code>getly.config.json</code> (or the{" "}
33
+ <code>NEXT_PUBLIC_GETLY_STORE_SLUG</code> env var) — the slug is
34
+ the part after <code>getly.store/store/</code> on your store page.
35
+ </p>
36
+ </header>
37
+ </main>
38
+ );
39
+ }
40
+
41
+ return (
42
+ <main className="container">
43
+ <header className="hero">
44
+ <p className="eyebrow">Digital products</p>
45
+ <h1>{data.store.name}</h1>
46
+ <p className="muted">
47
+ {data.items.length === 0
48
+ ? "No products published yet — new drops land here."
49
+ : `${data.items.length} product${data.items.length === 1 ? "" : "s"} available for instant download.`}
50
+ </p>
51
+ </header>
52
+
53
+ {data.items.length > 0 && (
54
+ <ul className="grid">
55
+ {data.items.map((product) => {
56
+ const cover = product.images[0];
57
+ const rating = formatRating(product.avgRating, product.reviewCount);
58
+ return (
59
+ <li key={product.id} className="card">
60
+ <Link href={`/p/${product.slug}`} className="card-media-link">
61
+ {cover ? (
62
+ <img
63
+ className="card-media"
64
+ src={cover.url}
65
+ alt={cover.altText ?? product.name}
66
+ loading="lazy"
67
+ />
68
+ ) : (
69
+ <span className="card-media card-media-empty" aria-hidden>
70
+ {product.name.slice(0, 1).toUpperCase()}
71
+ </span>
72
+ )}
73
+ </Link>
74
+ <div className="card-body">
75
+ <h2 className="card-title">
76
+ <Link href={`/p/${product.slug}`}>{product.name}</Link>
77
+ </h2>
78
+ {product.shortDescription && (
79
+ <p className="card-desc">{product.shortDescription}</p>
80
+ )}
81
+ <div className="card-footer">
82
+ <span className="price">
83
+ {formatPriceCents(product.priceCents, product.currency)}
84
+ </span>
85
+ {rating && <span className="rating">{rating}</span>}
86
+ </div>
87
+ <a
88
+ className="button"
89
+ href={product.urls.buy}
90
+ target="_blank"
91
+ rel="noopener noreferrer"
92
+ >
93
+ Buy now
94
+ </a>
95
+ </div>
96
+ </li>
97
+ );
98
+ })}
99
+ </ul>
100
+ )}
101
+ </main>
102
+ );
103
+ }
@@ -0,0 +1,5 @@
1
+ # Copy to .env.local to override getly.config.json without editing code.
2
+ NEXT_PUBLIC_GETLY_STORE_SLUG=__STORE_SLUG__
3
+
4
+ # Optional: point at a different Getly deployment.
5
+ # GETLY_API_URL=https://www.getly.store
@@ -0,0 +1,3 @@
1
+ {
2
+ "storeSlug": "__STORE_SLUG__"
3
+ }
@@ -0,0 +1,9 @@
1
+ node_modules/
2
+ .next/
3
+ out/
4
+ build/
5
+ .env.local
6
+ .env*.local
7
+ .DS_Store
8
+ *.tsbuildinfo
9
+ next-env.d.ts.bak
@@ -0,0 +1,12 @@
1
+ import config from "../getly.config.json";
2
+
3
+ /**
4
+ * The Getly store this storefront renders. The env var (set it in
5
+ * .env.local or on Vercel) overrides getly.config.json.
6
+ */
7
+ export const STORE_SLUG: string =
8
+ process.env.NEXT_PUBLIC_GETLY_STORE_SLUG || config.storeSlug;
9
+
10
+ /** Getly API origin. Override with GETLY_API_URL for a different deployment. */
11
+ export const GETLY_API_URL: string =
12
+ process.env.GETLY_API_URL || "https://www.getly.store";
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Typed helpers for Getly's PUBLIC storefront API (no auth, CORS *):
3
+ *
4
+ * GET /api/v1/public/stores/{slug}/products
5
+ * GET /api/v1/public/stores/{slug}/products/{productSlug}
6
+ *
7
+ * Responses use the { success, data } envelope; every price is INTEGER CENTS
8
+ * (`priceCents`). Pages fetch server-side with `revalidate: 300` — the same
9
+ * s-maxage the API itself serves.
10
+ */
11
+ import { GETLY_API_URL } from "./config";
12
+
13
+ export interface PublicProductImage {
14
+ url: string;
15
+ altText: string | null;
16
+ }
17
+
18
+ export interface PublicProduct {
19
+ id: string;
20
+ slug: string;
21
+ name: string;
22
+ shortDescription: string | null;
23
+ /** Present only on the single-product endpoint. */
24
+ description?: string | null;
25
+ /** Integer cents — the only money field this template reads. */
26
+ priceCents: number;
27
+ currency: string;
28
+ avgRating: number;
29
+ reviewCount: number;
30
+ images: PublicProductImage[];
31
+ urls: {
32
+ product: string;
33
+ buy: string;
34
+ };
35
+ }
36
+
37
+ export interface StoreProducts {
38
+ store: { id: string; name: string; slug: string };
39
+ items: PublicProduct[];
40
+ nextCursor: string | null;
41
+ }
42
+
43
+ const REVALIDATE_SECONDS = 300;
44
+
45
+ export async function getStoreProducts(
46
+ storeSlug: string,
47
+ ): Promise<StoreProducts | null> {
48
+ try {
49
+ const res = await fetch(
50
+ `${GETLY_API_URL}/api/v1/public/stores/${encodeURIComponent(storeSlug)}/products?limit=48`,
51
+ { next: { revalidate: REVALIDATE_SECONDS } },
52
+ );
53
+ if (!res.ok) return null;
54
+ const json = (await res.json()) as { success?: boolean; data?: StoreProducts };
55
+ return json.success && json.data ? json.data : null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ export async function getProduct(
62
+ storeSlug: string,
63
+ productSlug: string,
64
+ ): Promise<PublicProduct | null> {
65
+ try {
66
+ const res = await fetch(
67
+ `${GETLY_API_URL}/api/v1/public/stores/${encodeURIComponent(storeSlug)}/products/${encodeURIComponent(productSlug)}`,
68
+ { next: { revalidate: REVALIDATE_SECONDS } },
69
+ );
70
+ if (!res.ok) return null;
71
+ const json = (await res.json()) as { success?: boolean; data?: PublicProduct };
72
+ return json.success && json.data ? json.data : null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ export function formatPriceCents(priceCents: number, currency = "USD"): string {
79
+ if (priceCents === 0) return "Free";
80
+ return new Intl.NumberFormat("en-US", {
81
+ style: "currency",
82
+ currency,
83
+ }).format(priceCents / 100);
84
+ }
85
+
86
+ export function formatRating(avgRating: number, reviewCount: number): string | null {
87
+ if (reviewCount === 0) return null;
88
+ return `★ ${avgRating.toFixed(1)} · ${reviewCount} review${reviewCount === 1 ? "" : "s"}`;
89
+ }
90
+
91
+ /**
92
+ * Product descriptions may contain rich-text HTML (sellers write them in the
93
+ * Getly dashboard editor). This template deliberately does NOT inject that
94
+ * HTML (no dangerouslySetInnerHTML) — it strips tags and renders plain
95
+ * paragraphs instead. Safe by construction.
96
+ */
97
+ export function descriptionToParagraphs(
98
+ description: string | null | undefined,
99
+ ): string[] {
100
+ if (!description) return [];
101
+ const text = description
102
+ // Block-level closers become paragraph breaks before tags are stripped.
103
+ .replace(/<\/(p|div|h[1-6]|li|blockquote|pre)>/gi, "\n\n")
104
+ .replace(/<(br|hr)\s*\/?>/gi, "\n")
105
+ .replace(/<[^>]+>/g, "")
106
+ .replace(/&nbsp;/gi, " ")
107
+ .replace(/&amp;/gi, "&")
108
+ .replace(/&lt;/gi, "<")
109
+ .replace(/&gt;/gi, ">")
110
+ .replace(/&#39;|&apos;/gi, "'")
111
+ .replace(/&quot;/gi, '"');
112
+ return text
113
+ .split(/\n{2,}/)
114
+ .map((p) => p.replace(/\s+/g, " ").trim())
115
+ .filter(Boolean);
116
+ }
@@ -0,0 +1,5 @@
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+
4
+ // NOTE: This file should not be edited
5
+ // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -0,0 +1,4 @@
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {};
3
+
4
+ export default nextConfig;
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "__APP_NAME__",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "dev": "next dev",
7
+ "build": "next build",
8
+ "start": "next start",
9
+ "typecheck": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "next": "^15.5.0",
13
+ "react": "^19.0.0",
14
+ "react-dom": "^19.0.0"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^20.11.0",
18
+ "@types/react": "^19.0.0",
19
+ "@types/react-dom": "^19.0.0",
20
+ "typescript": "^5.7.0"
21
+ }
22
+ }
@@ -0,0 +1,21 @@
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": "preserve",
15
+ "incremental": true,
16
+ "plugins": [{ "name": "next" }],
17
+ "paths": { "@/*": ["./*"] }
18
+ },
19
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
20
+ "exclude": ["node_modules"]
21
+ }
@@ -0,0 +1,4 @@
1
+ {
2
+ "$schema": "https://openapi.vercel.sh/vercel.json",
3
+ "framework": "nextjs"
4
+ }