@questpackages/tours 0.1.0-test.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/CHANGELOG.md +15 -0
- package/README.md +122 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +219 -0
- package/dist/cli/templates.d.ts +12 -0
- package/dist/cli/templates.js +100 -0
- package/dist/next/index.d.ts +4 -0
- package/dist/next/index.js +4 -0
- package/dist/next/metadata.d.ts +26 -0
- package/dist/next/metadata.js +58 -0
- package/dist/next/revalidate-handler.d.ts +18 -0
- package/dist/next/revalidate-handler.js +42 -0
- package/dist/next/signature.d.ts +12 -0
- package/dist/next/signature.js +26 -0
- package/dist/react/booking-steps.d.ts +22 -0
- package/dist/react/booking-steps.js +23 -0
- package/dist/react/booking-wizard.d.ts +12 -0
- package/dist/react/booking-wizard.js +68 -0
- package/dist/react/contact-dialog.d.ts +15 -0
- package/dist/react/contact-dialog.js +45 -0
- package/dist/react/contact-panel.d.ts +5 -0
- package/dist/react/contact-panel.js +15 -0
- package/dist/react/contact-shared.d.ts +55 -0
- package/dist/react/contact-shared.js +60 -0
- package/dist/react/countries.d.ts +4 -0
- package/dist/react/countries.js +6 -0
- package/dist/react/index.d.ts +12 -0
- package/dist/react/index.js +11 -0
- package/dist/react/inquiry-wizard.d.ts +5 -0
- package/dist/react/inquiry-wizard.js +43 -0
- package/dist/react/party-size-rules.d.ts +18 -0
- package/dist/react/party-size-rules.js +52 -0
- package/dist/react/party-size.d.ts +27 -0
- package/dist/react/party-size.js +81 -0
- package/dist/react/sections.d.ts +10 -0
- package/dist/react/sections.js +58 -0
- package/dist/react/shared.d.ts +50 -0
- package/dist/react/shared.js +39 -0
- package/dist/react/tour-card.d.ts +30 -0
- package/dist/react/tour-card.js +22 -0
- package/dist/react/tour-detail.d.ts +26 -0
- package/dist/react/tour-detail.js +55 -0
- package/dist/react/tour-gallery.d.ts +4 -0
- package/dist/react/tour-gallery.js +35 -0
- package/dist/react/tour-json-ld.d.ts +7 -0
- package/dist/react/tour-json-ld.js +13 -0
- package/dist/schema/index.d.ts +805 -0
- package/dist/schema/index.js +249 -0
- package/dist/server/client.d.ts +43 -0
- package/dist/server/client.js +85 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +2 -0
- package/dist/styles.css +1395 -0
- package/package.json +67 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@questpackages/tours`. Versions follow semver. Pre-releases
|
|
4
|
+
(`x.y.z-test.n`) are published on the `test` dist-tag; stable releases on `latest`.
|
|
5
|
+
|
|
6
|
+
## [0.1.0-test.0] - 2026-09-28
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- Public API v1 client: `createQuestTours()` with `getTours`, `getTour`, `getTags`.
|
|
10
|
+
- Zod schemas and types for the Quest tours contract (`@questpackages/tours/schema`).
|
|
11
|
+
- React components: `TourGrid`, `TourCard`, `TourDetail` and per-section components.
|
|
12
|
+
- Styled defaults scoped under `.quest-tours`, themeable with `--quest-*` variables.
|
|
13
|
+
- Enquiry form posting to Quest; party-size pricing; gallery lightbox.
|
|
14
|
+
- Next.js helpers: `createRevalidateHandler`, `tourMetadata`, `tourSitemapEntries`, `TourJsonLd`.
|
|
15
|
+
- CLI: `quest-tours init` and `quest-tours doctor`.
|
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# @questpackages/tours
|
|
2
|
+
|
|
3
|
+
Show your Quest tour packages on your own Next.js website, with styled tour cards and
|
|
4
|
+
tour detail pages out of the box. Tours are edited in Quest; your site updates within
|
|
5
|
+
seconds of every change.
|
|
6
|
+
|
|
7
|
+
- Server-rendered on your domain (good for SEO), with metadata and JSON-LD built by Quest
|
|
8
|
+
- Only renders what Quest models: every field is typed, empty sections render nothing
|
|
9
|
+
- Themeable with CSS variables, section-by-section overrides when you need more
|
|
10
|
+
- Enquiry form sends leads straight into Quest
|
|
11
|
+
|
|
12
|
+
Requires Next.js 15+ (App Router) and React 19.
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
1. In Quest, open **Packages > Website embeds > Coded website** and copy your **site token**
|
|
17
|
+
and **webhook secret**. Add your site's origin (e.g. `https://www.example.com`) to
|
|
18
|
+
**allowed origins**.
|
|
19
|
+
2. In your Next.js project:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @questpackages/tours
|
|
23
|
+
npx @questpackages/tours init \
|
|
24
|
+
--site-token <site token> \
|
|
25
|
+
--origin https://app.questheaven.com \
|
|
26
|
+
--site-url https://www.example.com \
|
|
27
|
+
--webhook-secret <webhook secret>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`init` adds the env vars to `.env.local` and creates (never overwrites):
|
|
31
|
+
|
|
32
|
+
| File | Purpose |
|
|
33
|
+
|------|---------|
|
|
34
|
+
| `lib/quest.ts` | Shared Quest client |
|
|
35
|
+
| `app/tours/page.tsx` | Tour list (`--route` changes `tours`) |
|
|
36
|
+
| `app/tours/[slug]/page.tsx` | Tour detail with metadata and JSON-LD |
|
|
37
|
+
| `app/api/quest/revalidate/route.ts` | Quest calls this when a package changes |
|
|
38
|
+
| `AGENTS.md` | Notes for AI coding agents working on your site |
|
|
39
|
+
|
|
40
|
+
3. In Quest, set the **revalidate URL** to `https://www.example.com/api/quest/revalidate`.
|
|
41
|
+
4. Add the same env vars to your host (e.g. Vercel), deploy, then run:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npx @questpackages/tours doctor
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Environment variables
|
|
48
|
+
|
|
49
|
+
| Name | Required | Example |
|
|
50
|
+
|------|----------|---------|
|
|
51
|
+
| `QUEST_SITE_TOKEN` | yes | from Quest Website embeds |
|
|
52
|
+
| `QUEST_ORIGIN` | yes | `https://app.questheaven.com` |
|
|
53
|
+
| `QUEST_SITE_URL` | recommended | `https://www.example.com` (canonical URLs and JSON-LD) |
|
|
54
|
+
| `QUEST_WEBHOOK_SECRET` | recommended | from Quest Website embeds (instant updates) |
|
|
55
|
+
|
|
56
|
+
Without the webhook, pages refresh every 5 minutes.
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
import Link from "next/link";
|
|
62
|
+
import { createQuestTours } from "@questpackages/tours/server";
|
|
63
|
+
import { TourGrid } from "@questpackages/tours/react";
|
|
64
|
+
import "@questpackages/tours/styles.css";
|
|
65
|
+
|
|
66
|
+
const quest = createQuestTours();
|
|
67
|
+
|
|
68
|
+
export default async function GorillaTours() {
|
|
69
|
+
const tours = await quest.getTours({ tag: "gorilla-trekking" });
|
|
70
|
+
return <TourGrid tours={tours} hrefBase="/tours" LinkComponent={Link} />;
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Data
|
|
75
|
+
|
|
76
|
+
| Call | Returns |
|
|
77
|
+
|------|---------|
|
|
78
|
+
| `quest.getTours({ tag?, featured? })` | `TourCard[]` (all pages) |
|
|
79
|
+
| `quest.getTour(slug, { url? })` | `{ tour, seo }` or `null` |
|
|
80
|
+
| `quest.getTags()` | `{ slug, label, count }[]` |
|
|
81
|
+
|
|
82
|
+
Types live in `@questpackages/tours/schema` (`Tour`, `TourCard`, `TourDay`, ...).
|
|
83
|
+
|
|
84
|
+
### Theming
|
|
85
|
+
|
|
86
|
+
```css
|
|
87
|
+
.quest-tours {
|
|
88
|
+
--quest-primary: #7a4b2a;
|
|
89
|
+
--quest-primary-contrast: #fff;
|
|
90
|
+
--quest-accent: #f5ece4;
|
|
91
|
+
--quest-font-heading: "Cormorant Garamond", serif;
|
|
92
|
+
--quest-font-body: "Inter", sans-serif;
|
|
93
|
+
--quest-radius: 6px;
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
All variables are listed at the top of `@questpackages/tours/styles.css`.
|
|
98
|
+
|
|
99
|
+
### Replacing or hiding sections
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
import { TourDetail, TourSection, type TourSectionProps } from "@questpackages/tours/react";
|
|
103
|
+
|
|
104
|
+
function MyItinerary({ tour }: TourSectionProps) {
|
|
105
|
+
return (
|
|
106
|
+
<TourSection id="quest-itinerary" title="Your journey">
|
|
107
|
+
{tour.days.map((day) => <p key={day.dayLabel}>{day.title}</p>)}
|
|
108
|
+
</TourSection>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
<TourDetail tour={tour} components={{ Itinerary: MyItinerary }} hide={["PaymentTerms"]} />;
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Sections: `Hero`, `Overview`, `Highlights`, `Itinerary`, `Accommodation`, `Vehicle`, `Gallery`,
|
|
116
|
+
`Inclusions`, `PaymentTerms`, `Pricing`, `Enquiry`, `Related`. Overrides render on the server;
|
|
117
|
+
to use a client component, wrap it and pass only `tour`.
|
|
118
|
+
|
|
119
|
+
## Versions
|
|
120
|
+
|
|
121
|
+
- `latest`: production releases.
|
|
122
|
+
- `test`: pre-releases for Quest's test environments (`npm install @questpackages/tours@test`).
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, relative } from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { agentsSectionTemplate, detailPageTemplate, listPageTemplate, questModuleTemplate, revalidateRouteTemplate, } from "./templates.js";
|
|
6
|
+
const USAGE = `Usage:
|
|
7
|
+
npx @questpackages/tours init [--site-token <token>] [--origin <quest url>] [--site-url <your site url>]
|
|
8
|
+
[--webhook-secret <secret>] [--route tours] [--skip-agents-md]
|
|
9
|
+
npx @questpackages/tours doctor
|
|
10
|
+
|
|
11
|
+
Find the site token and webhook secret in Quest: Packages > Website embeds > Coded website.`;
|
|
12
|
+
function parseFlags(args) {
|
|
13
|
+
const flags = {};
|
|
14
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
15
|
+
const arg = args[index] ?? "";
|
|
16
|
+
if (!arg.startsWith("--"))
|
|
17
|
+
continue;
|
|
18
|
+
const [key, inline] = arg.slice(2).split("=", 2);
|
|
19
|
+
if (!key)
|
|
20
|
+
continue;
|
|
21
|
+
const next = args[index + 1];
|
|
22
|
+
const hasValue = inline === undefined && next !== undefined && !next.startsWith("--");
|
|
23
|
+
flags[key] = inline ?? (hasValue ? next : "");
|
|
24
|
+
if (hasValue)
|
|
25
|
+
index += 1;
|
|
26
|
+
}
|
|
27
|
+
return flags;
|
|
28
|
+
}
|
|
29
|
+
function readEnvFile(path) {
|
|
30
|
+
if (!existsSync(path))
|
|
31
|
+
return {};
|
|
32
|
+
const env = {};
|
|
33
|
+
for (const line of readFileSync(path, "utf8").split(/\r?\n/)) {
|
|
34
|
+
const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line);
|
|
35
|
+
if (!match?.[1])
|
|
36
|
+
continue;
|
|
37
|
+
env[match[1]] = (match[2] ?? "").replace(/^["']|["']$/g, "");
|
|
38
|
+
}
|
|
39
|
+
return env;
|
|
40
|
+
}
|
|
41
|
+
function projectEnv(cwd) {
|
|
42
|
+
return { ...readEnvFile(join(cwd, ".env")), ...readEnvFile(join(cwd, ".env.local")), ...process.env };
|
|
43
|
+
}
|
|
44
|
+
function importPath(fromFile, toFileWithoutExt) {
|
|
45
|
+
const path = relative(dirname(fromFile), toFileWithoutExt).split("\\").join("/");
|
|
46
|
+
return path.startsWith(".") ? path : `./${path}`;
|
|
47
|
+
}
|
|
48
|
+
function writeIfMissing(cwd, path, content) {
|
|
49
|
+
const label = relative(cwd, path);
|
|
50
|
+
if (existsSync(path)) {
|
|
51
|
+
console.log(` skip ${label} (already exists)`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
55
|
+
writeFileSync(path, content);
|
|
56
|
+
console.log(` create ${label}`);
|
|
57
|
+
}
|
|
58
|
+
async function ask(question, fallback) {
|
|
59
|
+
if (fallback)
|
|
60
|
+
return fallback;
|
|
61
|
+
if (!process.stdin.isTTY)
|
|
62
|
+
return undefined;
|
|
63
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
64
|
+
const answer = (await rl.question(`${question}: `)).trim();
|
|
65
|
+
rl.close();
|
|
66
|
+
return answer || undefined;
|
|
67
|
+
}
|
|
68
|
+
async function init(cwd, flags) {
|
|
69
|
+
const packageJsonPath = join(cwd, "package.json");
|
|
70
|
+
if (!existsSync(packageJsonPath)) {
|
|
71
|
+
console.error("Run this in the root of your Next.js project (no package.json found).");
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
const pkg = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
75
|
+
if (!pkg.dependencies?.next && !pkg.devDependencies?.next) {
|
|
76
|
+
console.error("This project does not use Next.js. The Quest tours SDK supports Next.js App Router.");
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
const srcDir = existsSync(join(cwd, "src", "app")) ? join(cwd, "src") : cwd;
|
|
80
|
+
const appDir = join(srcDir, "app");
|
|
81
|
+
if (!existsSync(appDir)) {
|
|
82
|
+
console.error("No app/ directory found. The Quest tours SDK supports Next.js App Router.");
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
const env = projectEnv(cwd);
|
|
86
|
+
const route = (flags.route ?? "tours").replace(/^\/+|\/+$/g, "");
|
|
87
|
+
const siteToken = await ask("Quest site token", flags["site-token"] ?? env.QUEST_SITE_TOKEN);
|
|
88
|
+
const origin = await ask("Quest origin (e.g. https://app.questheaven.com)", flags.origin ?? env.QUEST_ORIGIN);
|
|
89
|
+
const siteUrl = await ask("Your public site URL (e.g. https://www.example.com)", flags["site-url"] ?? env.QUEST_SITE_URL);
|
|
90
|
+
const webhookSecret = flags["webhook-secret"] ?? env.QUEST_WEBHOOK_SECRET;
|
|
91
|
+
if (!siteToken || !origin) {
|
|
92
|
+
console.error(`Missing --site-token or --origin.\n\n${USAGE}`);
|
|
93
|
+
return 1;
|
|
94
|
+
}
|
|
95
|
+
console.log("\nSetting up Quest tours:\n");
|
|
96
|
+
const envPath = join(cwd, ".env.local");
|
|
97
|
+
const existing = readEnvFile(envPath);
|
|
98
|
+
const additions = Object.entries({
|
|
99
|
+
QUEST_SITE_TOKEN: siteToken,
|
|
100
|
+
QUEST_ORIGIN: origin.replace(/\/+$/, ""),
|
|
101
|
+
QUEST_SITE_URL: siteUrl?.replace(/\/+$/, ""),
|
|
102
|
+
QUEST_WEBHOOK_SECRET: webhookSecret,
|
|
103
|
+
}).filter((entry) => Boolean(entry[1]) && !(entry[0] in existing));
|
|
104
|
+
if (additions.length > 0) {
|
|
105
|
+
appendFileSync(envPath, `\n# Quest tours\n${additions.map(([key, value]) => `${key}=${value}`).join("\n")}\n`);
|
|
106
|
+
console.log(` update .env.local (${additions.map(([key]) => key).join(", ")})`);
|
|
107
|
+
}
|
|
108
|
+
const questModule = join(srcDir, "lib", "quest.ts");
|
|
109
|
+
const listPage = join(appDir, route, "page.tsx");
|
|
110
|
+
const detailPage = join(appDir, route, "[slug]", "page.tsx");
|
|
111
|
+
const questModuleImport = questModule.replace(/\.ts$/, "");
|
|
112
|
+
writeIfMissing(cwd, questModule, questModuleTemplate(route));
|
|
113
|
+
writeIfMissing(cwd, listPage, listPageTemplate({
|
|
114
|
+
route,
|
|
115
|
+
questImportFromList: importPath(listPage, questModuleImport),
|
|
116
|
+
questImportFromDetail: importPath(detailPage, questModuleImport),
|
|
117
|
+
}));
|
|
118
|
+
writeIfMissing(cwd, detailPage, detailPageTemplate({
|
|
119
|
+
route,
|
|
120
|
+
questImportFromList: importPath(listPage, questModuleImport),
|
|
121
|
+
questImportFromDetail: importPath(detailPage, questModuleImport),
|
|
122
|
+
}));
|
|
123
|
+
writeIfMissing(cwd, join(appDir, "api", "quest", "revalidate", "route.ts"), revalidateRouteTemplate);
|
|
124
|
+
if (flags["skip-agents-md"] === undefined) {
|
|
125
|
+
// Repos spell it AGENTS.md, Agents.md or agents.md; reuse whichever exists.
|
|
126
|
+
const agentsFile = readdirSync(cwd).find((name) => name.toLowerCase() === "agents.md") ?? "AGENTS.md";
|
|
127
|
+
const agentsPath = join(cwd, agentsFile);
|
|
128
|
+
const agentsContent = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : "";
|
|
129
|
+
if (!agentsContent.includes("@questpackages/tours")) {
|
|
130
|
+
appendFileSync(agentsPath, agentsSectionTemplate(route));
|
|
131
|
+
console.log(` update ${agentsFile} (Quest tours section)`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
console.log(`
|
|
135
|
+
Next steps:
|
|
136
|
+
1. In Quest > Packages > Website embeds, add ${siteUrl ?? "your site origin"} to allowed origins
|
|
137
|
+
(needed for the enquiry form).
|
|
138
|
+
2. In Quest, set the revalidate URL to ${siteUrl ? `${siteUrl.replace(/\/+$/, "")}/api/quest/revalidate` : "<your site>/api/quest/revalidate"}
|
|
139
|
+
and copy the webhook secret into QUEST_WEBHOOK_SECRET${webhookSecret ? " (done)" : ""}.
|
|
140
|
+
3. Add the same env vars to your hosting provider (e.g. Vercel project settings).
|
|
141
|
+
4. Run: npx @questpackages/tours doctor
|
|
142
|
+
`);
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
async function check(label, run) {
|
|
146
|
+
try {
|
|
147
|
+
console.log(` ok ${label}: ${await run()}`);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
console.log(` FAIL ${label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function doctor(cwd) {
|
|
156
|
+
const env = projectEnv(cwd);
|
|
157
|
+
const origin = env.QUEST_ORIGIN?.replace(/\/+$/, "");
|
|
158
|
+
const siteToken = env.QUEST_SITE_TOKEN;
|
|
159
|
+
const siteUrl = env.QUEST_SITE_URL?.replace(/\/+$/, "");
|
|
160
|
+
const results = [];
|
|
161
|
+
console.log("\nChecking Quest tours setup:\n");
|
|
162
|
+
results.push(await check("QUEST_SITE_TOKEN", async () => {
|
|
163
|
+
if (!siteToken)
|
|
164
|
+
throw new Error("not set");
|
|
165
|
+
return "set";
|
|
166
|
+
}), await check("QUEST_ORIGIN", async () => {
|
|
167
|
+
if (!origin)
|
|
168
|
+
throw new Error("not set");
|
|
169
|
+
return origin;
|
|
170
|
+
}), await check("QUEST_WEBHOOK_SECRET", async () => {
|
|
171
|
+
if (!env.QUEST_WEBHOOK_SECRET)
|
|
172
|
+
throw new Error("not set. Tour pages will only refresh every 5 minutes");
|
|
173
|
+
return "set";
|
|
174
|
+
}));
|
|
175
|
+
if (origin && siteToken) {
|
|
176
|
+
const base = `${origin}/api/public/v1/sites/${encodeURIComponent(siteToken)}`;
|
|
177
|
+
results.push(await check("Quest API", async () => {
|
|
178
|
+
const response = await fetch(`${base}/tours?limit=24`);
|
|
179
|
+
const body = (await response.json().catch(() => null));
|
|
180
|
+
if (!response.ok)
|
|
181
|
+
throw new Error(body?.error?.message ?? `HTTP ${response.status}`);
|
|
182
|
+
if (body?.apiVersion !== 1)
|
|
183
|
+
throw new Error(`unexpected API version ${body?.apiVersion}`);
|
|
184
|
+
return `${body.data?.totalCount ?? 0} published tours`;
|
|
185
|
+
}));
|
|
186
|
+
}
|
|
187
|
+
if (siteUrl) {
|
|
188
|
+
results.push(await check("Revalidate endpoint", async () => {
|
|
189
|
+
const response = await fetch(`${siteUrl}/api/quest/revalidate`, { method: "POST", body: "{}" });
|
|
190
|
+
if (response.status === 401)
|
|
191
|
+
return "reachable and rejecting unsigned requests";
|
|
192
|
+
if (response.status === 404)
|
|
193
|
+
throw new Error("not found. Deploy app/api/quest/revalidate/route.ts");
|
|
194
|
+
if (response.status === 500)
|
|
195
|
+
throw new Error("QUEST_WEBHOOK_SECRET is not set on the deployed site");
|
|
196
|
+
throw new Error(`unexpected HTTP ${response.status}`);
|
|
197
|
+
}));
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
console.log(" skip Revalidate endpoint: set QUEST_SITE_URL to check the deployed site");
|
|
201
|
+
}
|
|
202
|
+
const failed = results.filter((ok) => !ok).length;
|
|
203
|
+
console.log(failed === 0 ? "\nAll checks passed.\n" : `\n${failed} check(s) failed.\n`);
|
|
204
|
+
return failed === 0 ? 0 : 1;
|
|
205
|
+
}
|
|
206
|
+
async function main() {
|
|
207
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
208
|
+
const cwd = process.cwd();
|
|
209
|
+
if (command === "init")
|
|
210
|
+
return init(cwd, parseFlags(rest));
|
|
211
|
+
if (command === "doctor")
|
|
212
|
+
return doctor(cwd);
|
|
213
|
+
console.log(USAGE);
|
|
214
|
+
return command ? 1 : 0;
|
|
215
|
+
}
|
|
216
|
+
main().then((code) => process.exit(code), (error) => {
|
|
217
|
+
console.error(error);
|
|
218
|
+
process.exit(1);
|
|
219
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type TemplateInput = {
|
|
2
|
+
/** URL path segment for tour pages, e.g. "tours" -> /tours and /tours/<slug>. */
|
|
3
|
+
route: string;
|
|
4
|
+
/** Import specifier for the generated quest module from each file. */
|
|
5
|
+
questImportFromList: string;
|
|
6
|
+
questImportFromDetail: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function questModuleTemplate(route: string): string;
|
|
9
|
+
export declare function listPageTemplate(input: TemplateInput): string;
|
|
10
|
+
export declare function detailPageTemplate(input: TemplateInput): string;
|
|
11
|
+
export declare const revalidateRouteTemplate = "import { createRevalidateHandler } from \"@questpackages/tours/next\";\n\n// Quest calls this endpoint when a tour package changes, so tour pages refresh within seconds.\nexport const { POST } = createRevalidateHandler();\n";
|
|
12
|
+
export declare function agentsSectionTemplate(route: string): string;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export function questModuleTemplate(route) {
|
|
2
|
+
return `import { createQuestTours } from "@questpackages/tours/server";
|
|
3
|
+
|
|
4
|
+
/** Quest tours client. Reads QUEST_SITE_TOKEN and QUEST_ORIGIN from the environment. */
|
|
5
|
+
export const quest = createQuestTours();
|
|
6
|
+
|
|
7
|
+
/** Base path of the tour pages on this site. */
|
|
8
|
+
export const TOURS_PATH = "/${route}";
|
|
9
|
+
|
|
10
|
+
/** Absolute public URL of a tour page, used by Quest to build canonical URLs and JSON-LD. */
|
|
11
|
+
export function tourUrl(slug: string): string | undefined {
|
|
12
|
+
const siteUrl = process.env.QUEST_SITE_URL?.replace(/\\/+$/, "");
|
|
13
|
+
return siteUrl ? \`\${siteUrl}\${TOURS_PATH}/\${slug}\` : undefined;
|
|
14
|
+
}
|
|
15
|
+
`;
|
|
16
|
+
}
|
|
17
|
+
export function listPageTemplate(input) {
|
|
18
|
+
return `import type { Metadata } from "next";
|
|
19
|
+
import Link from "next/link";
|
|
20
|
+
import { TourGrid } from "@questpackages/tours/react";
|
|
21
|
+
import "@questpackages/tours/styles.css";
|
|
22
|
+
import { quest, TOURS_PATH } from "${input.questImportFromList}";
|
|
23
|
+
|
|
24
|
+
export const metadata: Metadata = {
|
|
25
|
+
title: "Tours",
|
|
26
|
+
description: "Browse our tours and safaris.",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export default async function ToursPage() {
|
|
30
|
+
const tours = await quest.getTours();
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<main style={{ maxWidth: "80rem", margin: "0 auto", padding: "2.5rem 1rem" }}>
|
|
34
|
+
<h1 style={{ marginBottom: "2rem" }}>Our tours</h1>
|
|
35
|
+
<TourGrid tours={tours} hrefBase={TOURS_PATH} LinkComponent={Link} />
|
|
36
|
+
</main>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
`;
|
|
40
|
+
}
|
|
41
|
+
export function detailPageTemplate(input) {
|
|
42
|
+
return `import { notFound } from "next/navigation";
|
|
43
|
+
import Link from "next/link";
|
|
44
|
+
import { tourMetadata } from "@questpackages/tours/next";
|
|
45
|
+
import { TourDetail, TourJsonLd } from "@questpackages/tours/react";
|
|
46
|
+
import "@questpackages/tours/styles.css";
|
|
47
|
+
import { quest, TOURS_PATH, tourUrl } from "${input.questImportFromDetail}";
|
|
48
|
+
|
|
49
|
+
type TourPageProps = { params: Promise<{ slug: string }> };
|
|
50
|
+
|
|
51
|
+
// Pre-render known tours at build time. New tours render on first visit.
|
|
52
|
+
export async function generateStaticParams() {
|
|
53
|
+
const tours = await quest.getTours().catch(() => []);
|
|
54
|
+
return tours.map((tour) => ({ slug: tour.slug }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function generateMetadata({ params }: TourPageProps) {
|
|
58
|
+
const { slug } = await params;
|
|
59
|
+
const result = await quest.getTour(slug, { url: tourUrl(slug) });
|
|
60
|
+
return result ? tourMetadata(result) : {};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default async function TourPage({ params }: TourPageProps) {
|
|
64
|
+
const { slug } = await params;
|
|
65
|
+
const result = await quest.getTour(slug, { url: tourUrl(slug) });
|
|
66
|
+
if (!result) notFound();
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<main style={{ maxWidth: "80rem", margin: "0 auto", padding: "1.5rem 1rem 4rem" }}>
|
|
70
|
+
<TourJsonLd seo={result.seo} />
|
|
71
|
+
<TourDetail tour={result.tour} hrefBase={TOURS_PATH} LinkComponent={Link} />
|
|
72
|
+
</main>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
`;
|
|
76
|
+
}
|
|
77
|
+
export const revalidateRouteTemplate = `import { createRevalidateHandler } from "@questpackages/tours/next";
|
|
78
|
+
|
|
79
|
+
// Quest calls this endpoint when a tour package changes, so tour pages refresh within seconds.
|
|
80
|
+
export const { POST } = createRevalidateHandler();
|
|
81
|
+
`;
|
|
82
|
+
export function agentsSectionTemplate(route) {
|
|
83
|
+
return `
|
|
84
|
+
## Quest tours (@questpackages/tours)
|
|
85
|
+
|
|
86
|
+
Tour cards and tour detail pages on this site come from Quest (the operator's tour
|
|
87
|
+
package system) through \`@questpackages/tours\`. Tours are edited in Quest, not in this repo.
|
|
88
|
+
|
|
89
|
+
- Pages: \`/${route}\` (list) and \`/${route}/[slug]\` (detail). Shared client in the generated \`quest\` module.
|
|
90
|
+
- Env vars: \`QUEST_SITE_TOKEN\`, \`QUEST_ORIGIN\`, \`QUEST_SITE_URL\`, \`QUEST_WEBHOOK_SECRET\`.
|
|
91
|
+
- Content model: only render fields that exist on the SDK types (\`Tour\`, \`TourCard\` from
|
|
92
|
+
\`@questpackages/tours/schema\`). Do not add tour sections Quest does not provide (for example
|
|
93
|
+
"What to pack" or FAQs); they cannot be filled from Quest data.
|
|
94
|
+
- Styling: override \`--quest-*\` CSS variables on \`.quest-tours\`, or replace a section with
|
|
95
|
+
\`<TourDetail components={{ Itinerary: MyItinerary }} />\`. Hide one with \`hide={["PaymentTerms"]}\`.
|
|
96
|
+
- Freshness: Quest POSTs to \`/api/quest/revalidate\` on every package change. Keep that route.
|
|
97
|
+
- Enquiries post from the browser to Quest. This site's origin must be in Quest Website embeds allowed origins.
|
|
98
|
+
- Check the setup: \`npx @questpackages/tours doctor\`.
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Metadata, MetadataRoute } from "next";
|
|
2
|
+
import type { TourCard } from "../schema/index.js";
|
|
3
|
+
import type { TourWithSeo } from "../server/client.js";
|
|
4
|
+
/**
|
|
5
|
+
* Next.js metadata for a tour detail page. Uses the SEO Quest built for this page URL
|
|
6
|
+
* (pass `url` to `getTour`), falling back to the tour content.
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* export async function generateMetadata({ params }) {
|
|
10
|
+
* const result = await quest.getTour(slug, { url });
|
|
11
|
+
* return result ? tourMetadata(result) : {};
|
|
12
|
+
* }
|
|
13
|
+
* ```
|
|
14
|
+
*/
|
|
15
|
+
export declare function tourMetadata({ tour, seo }: TourWithSeo): Metadata;
|
|
16
|
+
/**
|
|
17
|
+
* Sitemap entries for Quest tours, for `app/sitemap.ts`.
|
|
18
|
+
*
|
|
19
|
+
* ```ts
|
|
20
|
+
* tourSitemapEntries(await quest.getTours(), { baseUrl: "https://example.com", path: (slug) => `/tours/${slug}` })
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare function tourSitemapEntries(tours: TourCard[], options: {
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
path: (slug: string) => string;
|
|
26
|
+
}): MetadataRoute.Sitemap;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
const META_DESCRIPTION_MAX = 160;
|
|
2
|
+
function truncate(text, max) {
|
|
3
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
4
|
+
if (clean.length <= max)
|
|
5
|
+
return clean;
|
|
6
|
+
const cut = clean.slice(0, max - 1);
|
|
7
|
+
return `${cut.slice(0, Math.max(cut.lastIndexOf(" "), max - 20)).trimEnd()}…`;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Next.js metadata for a tour detail page. Uses the SEO Quest built for this page URL
|
|
11
|
+
* (pass `url` to `getTour`), falling back to the tour content.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* export async function generateMetadata({ params }) {
|
|
15
|
+
* const result = await quest.getTour(slug, { url });
|
|
16
|
+
* return result ? tourMetadata(result) : {};
|
|
17
|
+
* }
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export function tourMetadata({ tour, seo }) {
|
|
21
|
+
const title = seo?.title ?? `${tour.title} | ${tour.durationLabel} by ${tour.operator.name}`;
|
|
22
|
+
const description = truncate(seo?.description ?? tour.summary ?? tour.overview[0] ?? tour.title, META_DESCRIPTION_MAX);
|
|
23
|
+
const image = seo?.ogImage ?? tour.heroImage?.url ?? null;
|
|
24
|
+
return {
|
|
25
|
+
// Quest titles already carry the operator name, so skip the site title template.
|
|
26
|
+
title: { absolute: title },
|
|
27
|
+
description,
|
|
28
|
+
...(seo ? { alternates: { canonical: seo.canonicalUrl } } : {}),
|
|
29
|
+
openGraph: {
|
|
30
|
+
title,
|
|
31
|
+
description,
|
|
32
|
+
type: "website",
|
|
33
|
+
...(seo ? { url: seo.canonicalUrl } : {}),
|
|
34
|
+
...(image ? { images: [{ url: image, alt: tour.title }] } : {}),
|
|
35
|
+
},
|
|
36
|
+
twitter: {
|
|
37
|
+
card: image ? "summary_large_image" : "summary",
|
|
38
|
+
title,
|
|
39
|
+
description,
|
|
40
|
+
...(image ? { images: [image] } : {}),
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Sitemap entries for Quest tours, for `app/sitemap.ts`.
|
|
46
|
+
*
|
|
47
|
+
* ```ts
|
|
48
|
+
* tourSitemapEntries(await quest.getTours(), { baseUrl: "https://example.com", path: (slug) => `/tours/${slug}` })
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export function tourSitemapEntries(tours, options) {
|
|
52
|
+
const base = options.baseUrl.replace(/\/+$/, "");
|
|
53
|
+
return tours.map((tour) => ({
|
|
54
|
+
url: `${base}${options.path(tour.slug)}`,
|
|
55
|
+
changeFrequency: "weekly",
|
|
56
|
+
priority: tour.featured ? 0.9 : 0.8,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type RevalidateHandlerOptions = {
|
|
2
|
+
/** Defaults to `process.env.QUEST_WEBHOOK_SECRET`. */
|
|
3
|
+
secret?: string;
|
|
4
|
+
/** Defaults to `process.env.QUEST_SITE_TOKEN`. Events for other sites are ignored. */
|
|
5
|
+
siteToken?: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Route handler for `app/api/quest/revalidate/route.ts`:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* export const { POST } = createRevalidateHandler();
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Quest calls it whenever a package changes, so tour pages refresh within seconds.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createRevalidateHandler(options?: RevalidateHandlerOptions): {
|
|
17
|
+
POST(request: Request): Promise<Response>;
|
|
18
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { revalidateTag } from "next/cache";
|
|
2
|
+
import { toursChangedEventSchema } from "../schema/index.js";
|
|
3
|
+
import { QUEST_TOURS_CACHE_TAG } from "../server/client.js";
|
|
4
|
+
import { QUEST_SIGNATURE_HEADER, verifyQuestSignature } from "./signature.js";
|
|
5
|
+
/**
|
|
6
|
+
* Route handler for `app/api/quest/revalidate/route.ts`:
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* export const { POST } = createRevalidateHandler();
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* Quest calls it whenever a package changes, so tour pages refresh within seconds.
|
|
13
|
+
*/
|
|
14
|
+
export function createRevalidateHandler(options = {}) {
|
|
15
|
+
return {
|
|
16
|
+
async POST(request) {
|
|
17
|
+
const secret = (options.secret ?? process.env.QUEST_WEBHOOK_SECRET)?.trim();
|
|
18
|
+
if (!secret) {
|
|
19
|
+
return Response.json({ error: "QUEST_WEBHOOK_SECRET is not set on this site." }, { status: 500 });
|
|
20
|
+
}
|
|
21
|
+
const body = await request.text();
|
|
22
|
+
const verified = verifyQuestSignature({
|
|
23
|
+
secret,
|
|
24
|
+
header: request.headers.get(QUEST_SIGNATURE_HEADER),
|
|
25
|
+
body,
|
|
26
|
+
});
|
|
27
|
+
if (!verified) {
|
|
28
|
+
return Response.json({ error: "Invalid signature." }, { status: 401 });
|
|
29
|
+
}
|
|
30
|
+
const event = toursChangedEventSchema.safeParse(JSON.parse(body));
|
|
31
|
+
if (!event.success) {
|
|
32
|
+
return Response.json({ error: "Unknown event." }, { status: 400 });
|
|
33
|
+
}
|
|
34
|
+
const siteToken = (options.siteToken ?? process.env.QUEST_SITE_TOKEN)?.trim();
|
|
35
|
+
if (siteToken && event.data.siteToken !== siteToken) {
|
|
36
|
+
return Response.json({ revalidated: false, reason: "Different site." }, { status: 202 });
|
|
37
|
+
}
|
|
38
|
+
revalidateTag(QUEST_TOURS_CACHE_TAG, { expire: 0 });
|
|
39
|
+
return Response.json({ revalidated: true });
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const QUEST_SIGNATURE_HEADER = "x-quest-signature";
|
|
2
|
+
/** Reject events older (or newer) than this, to limit replay of a captured request. */
|
|
3
|
+
export declare const QUEST_SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
4
|
+
/**
|
|
5
|
+
* Verify `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">` as sent by Quest.
|
|
6
|
+
*/
|
|
7
|
+
export declare function verifyQuestSignature(input: {
|
|
8
|
+
secret: string;
|
|
9
|
+
header: string | null;
|
|
10
|
+
body: string;
|
|
11
|
+
nowSeconds?: number;
|
|
12
|
+
}): boolean;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
export const QUEST_SIGNATURE_HEADER = "x-quest-signature";
|
|
3
|
+
/** Reject events older (or newer) than this, to limit replay of a captured request. */
|
|
4
|
+
export const QUEST_SIGNATURE_TOLERANCE_SECONDS = 300;
|
|
5
|
+
/**
|
|
6
|
+
* Verify `t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">` as sent by Quest.
|
|
7
|
+
*/
|
|
8
|
+
export function verifyQuestSignature(input) {
|
|
9
|
+
if (!input.header)
|
|
10
|
+
return false;
|
|
11
|
+
const parts = new Map(input.header.split(",").map((part) => {
|
|
12
|
+
const [key, ...rest] = part.trim().split("=");
|
|
13
|
+
return [key ?? "", rest.join("=")];
|
|
14
|
+
}));
|
|
15
|
+
const timestamp = Number(parts.get("t"));
|
|
16
|
+
const signature = parts.get("v1") ?? "";
|
|
17
|
+
if (!Number.isInteger(timestamp) || !/^[0-9a-f]{64}$/.test(signature))
|
|
18
|
+
return false;
|
|
19
|
+
const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
20
|
+
if (Math.abs(now - timestamp) > QUEST_SIGNATURE_TOLERANCE_SECONDS)
|
|
21
|
+
return false;
|
|
22
|
+
const expected = createHmac("sha256", input.secret)
|
|
23
|
+
.update(`${timestamp}.${input.body}`)
|
|
24
|
+
.digest();
|
|
25
|
+
return timingSafeEqual(expected, Buffer.from(signature, "hex"));
|
|
26
|
+
}
|