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 +59 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.js +98 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +99 -0
- package/package.json +38 -0
- package/template/README.md +75 -0
- package/template/app/globals.css +332 -0
- package/template/app/layout.tsx +39 -0
- package/template/app/p/[slug]/page.tsx +99 -0
- package/template/app/page.tsx +103 -0
- package/template/env.local.example +5 -0
- package/template/getly.config.json +3 -0
- package/template/gitignore +9 -0
- package/template/lib/config.ts +12 -0
- package/template/lib/getly.ts +116 -0
- package/template/next-env.d.ts +5 -0
- package/template/next.config.mjs +4 -0
- package/template/package.json +22 -0
- package/template/tsconfig.json +21 -0
- package/template/vercel.json +4 -0
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
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
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
[](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="/">← 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 “{STORE_SLUG}”.
|
|
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,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(/ /gi, " ")
|
|
107
|
+
.replace(/&/gi, "&")
|
|
108
|
+
.replace(/</gi, "<")
|
|
109
|
+
.replace(/>/gi, ">")
|
|
110
|
+
.replace(/'|'/gi, "'")
|
|
111
|
+
.replace(/"/gi, '"');
|
|
112
|
+
return text
|
|
113
|
+
.split(/\n{2,}/)
|
|
114
|
+
.map((p) => p.replace(/\s+/g, " ").trim())
|
|
115
|
+
.filter(Boolean);
|
|
116
|
+
}
|
|
@@ -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
|
+
}
|