1{2 "solution": "faq",3 "requires": [4 "shared"5 ],6 "entrypoints": [7 "js/wix/faq-store.js",8 "js/wix/faq.js"9 ],10 "files": {11 "js/wix/faq-core.js": "// Declarations of faq-core.ts: what this file exports, their arguments and what they return.\n// import type { FaqCategory, FaqData, FaqQuestion, FaqQuestionPage, FaqSection, FetchQuestionsOptions } from \"./faq-types.js
12 "js/wix/faq-store.js": "// Declarations of faq-store.ts: what this file exports, their arguments and what they return.\n// import type { FaqData, FaqQuestion } from \"./faq-types.js\";\n// export interface FaqState {\n// data: FaqData | null;\n// activeCategoryId: string | null;\n// query: string;\n// expandedIds: string[];\n// visible: FaqQuestion[];\n// resultsCount: number;\n// loading: boolean;\n// error: string | null;\n// }\n// export interface FaqStoreOptions {\n// initialData?: FaqData;\n// initialCategoryId?: string | null;\n// expandOnlyOne?: boolean;\n// openFirst?: boolean;\n// syncUrl?: boolean;\n// }\n// export declare function seedFaq(o?: FaqStoreOptions): void;\n// export declare function getFaqState(): FaqState;\n// export declare function subscribeFaq(fn: (state: FaqState) => void): () => void;\n// export declare function selectCategory(id: string | null): void;\n// export declare function setQuery(q: string): void;\n// export declare function toggleQuestion(id: string): void;\n// export declare function expandQuestion(id: string): void;\n// export declare function collapseQuestion(id: string): void;\n// export declare function collapseAll(): void;\n// export declare function retry(): void;\n// FAQ page state — a module-scope store, deliberately NOT a React context and not a per-surface\n// factory: the category nav, the search box, and the accordion are separate Astro islands on one\n// page and must share one state (a context can't span islands; each island is its own React root).\n// Framework-free — consume it through useFaq() (hooks/), or subscribe directly from a static page,\n// Vue, or Svelte. Same shape as the storefront cart store: getState/subscribe + actions, emit after\n// every change.\n//\n// Seeding: every island receives the SSR data and calls seedFaq(); on the server every seed replaces\n// the state (a request renders its own props), in the browser the first seed wins and the rest are\n// no-ops, so the SSR HTML and the first client paint agree. A page with no SSR data (a SPA) fetches on\n// the first subscription.\n//\n// URL state (syncUrl, default on): `?category=<id>` and `#q-<slug>` are read once when the first\n// island subscribes and written with history.replaceState on every change — a visitor can copy the\n// address bar at any point and get back to the same view.\nimport { fetchFaq } from \"./faq.js\";\nimport { anchorId, findByAnchor, parseDeepLink, visibleQuestions } from \"./faq-core.js\";\nconst EMPTY_DATA = { categories: [], questions: [], truncated: false };\nlet state = {\n data: null,\n activeCategoryId: null,\n query: \"\",\n expandedIds: [],\n visible: [],\n resultsCount: 0,\n loading: false,\n error: null,\n};\nlet expandOnlyOne = true;\nlet openFirst = false;\nlet syncUrl = true;\nlet seeded = false;\nlet started = false;\nlet generation = 0; // bumped by every load; a response from an older generation is dropped\nconst listeners = new Set();\nconst isBrowser = () => typeof window !== \"undefined\";\nfunction setState(patch) {\n const next = { ...state, ...patch };\n next.visible = visibleQuestions(next.data, next.activeCategoryId, next.query);\n next.resultsCount = next.query.trim() ? next.visible.length : 0;\n state = next;\n for (const l of listeners)\n l();\n}\nfunction writeUrl() {\n if (!syncUrl || !isBrowser() || typeof history === \"undefined\")\n return;\n const url = new URL(location.href);\n if (state.activeCategoryId)\n url.searchParams.set(\"category\", state.activeCategoryId);\n else\n url.searchParams.delete(\"category\");\n const open = state.expandedIds.length === 1 ? state.visible.find((q) => q.id === state.expandedIds[0]) : undefined;\n url.hash = open ? anchorId(open) : \"\";\n try {\n history.replaceState(history.state, \"\", url.pathname + url.search + url.hash);\n }\n catch {\n /* a sandboxed frame may refuse — the view is right, only the address bar lags */\n }\n}\n/** `?category` and `#q-…` from the address bar → state: the anchor's question expands and its category is shown. */\nfunction applyDeepLink() {\n if (!syncUrl || !isBrowser() || !state.data)\n return;\n const { categoryId, anchor } = parseDeepLink(location.search, location.hash);\n const patch = {};\n if (categoryId && state.data.categories.some((c) => c.id === categoryId))\n patch.activeCategoryId = categoryId;\n const target = anchor ? findByAnchor(state.data.questions, anchor) : undefined;\n if (target) {\n patch.expandedIds = expandOnlyOne ? [target.id] : [...new Set([...state.expandedIds, target.id])];\n // The deep link decides the category: a question hidden by the active filter switches the filter to its own.\n const active = patch.activeCategoryId ?? state.activeCategoryId;\n if (active && active !== target.categoryId)\n patch.activeCategoryId = target.categoryId;\n }\n if (Object.keys(patch).length)\n setState(patch);\n if (target && typeof document !== \"undefined\") {\n requestAnimationFrame(() => document.getElementById(anchorId(target))?.scrollIntoView({ block: \"start\" }));\n }\n}\nfunction load() {\n const id = ++generation;\n setState({ loading: true, error: null });\n fetchFaq()\n .then((data) => {\n if (generation !== id)\n return; // superseded — drop it\n setState({ data, loading: false, expandedIds: openFirst ? firstId(data) : state.expandedIds });\n applyDeepLink();\n })\n .catch((e) => {\n if (generation !== id)\n return;\n // An empty data set with an error: the accordion shows the error line and a retry, not skeletons forever.\n setState({ data: state.data ?? EMPTY_DATA, loading: false, error: e instanceof Error ? e.message : String(e) });\n });\n}\nfunction firstId(data) {\n const first = visibleQuestions(data, state.activeCategoryId, state.query)[0];\n return first ? [first.id] : [];\n}\nfunction start() {\n if (started)\n return;\n started = true;\n if (state.data)\n applyDeepLink();\n else\n load();\n}\n/**\n * Hand the store its SSR data and options. Call from every island (useFaq does) — on the server each\n * call replaces the state (one request, one data set); in the browser the first call seeds and later\n * calls are no-ops, so hydration matches the server HTML.\n */\nexport function seedFaq(o = {}) {\n if (o.expandOnlyOne !== undefined)\n expandOnlyOne = o.expandOnlyOne;\n if (o.openFirst !== undefined)\n openFirst = o.openFirst;\n if (o.syncUrl !== undefined)\n syncUrl = o.syncUrl;\n if (!o.initialData || (seeded && isBrowser()))\n return;\n seeded = true;\n const activeCategoryId = o.initialCategoryId && o.initialData.categories.some((c) => c.id === o.initialCategoryId) ? o.initialCategoryId : null;\n const data = o.initialData;\n state = { ...state, data, activeCategoryId, loading: false, error: null };\n state.visible = visibleQuestions(data, activeCategoryId, state.query);\n state.resultsCount = 0;\n state.expandedIds = openFirst ? firstId(data) : [];\n // No emit: seeding happens during render, before any subscriber exists.\n}\nexport function getFaqState() {\n return state;\n}\n/** The first browser subscriber starts the store: the deep link is applied, and unseeded data is fetched. */\nexport function subscribeFaq(fn) {\n const listener = () => { try {\n fn(getFaqState());\n }\n catch (e) {\n console.error(\"faq-store listener failed\", e);\n } };\n listeners.add(listener);\n if (isBrowser())\n start();\n return () => listeners.delete(listener);\n}\n/** Show one category (null = all). Keeps the search query — the two compose. */\nexport function selectCategory(id) {\n if (id === state.activeCategoryId)\n return;\n setState({ activeCategoryId: id });\n writeUrl();\n}\n/** Set the search query (debounce lives in the component). */\nexport function setQuery(q) {\n if (q === state.query)\n return;\n setState({ query: q });\n}\nexport function toggleQuestion(id) {\n if (state.expandedIds.includes(id))\n collapseQuestion(id);\n else\n expandQuestion(id);\n}\nexport function expandQuestion(id) {\n if (state.expandedIds.includes(id))\n return;\n setState({ expandedIds: expandOnlyOne ? [id] : [...state.expandedIds, id] });\n writeUrl();\n}\nexport function collapseQuestion(id) {\n if (!state.expandedIds.includes(id))\n return;\n setState({ expandedIds: state.expandedIds.filter((x) => x !== id) });\n writeUrl();\n}\nexport function collapseAll() {\n if (!state.expandedIds.length)\n return;\n setState({ expandedIds: [] });\n writeUrl();\n}\n/** Re-run the load after an error. */\nexport function retry() {\n load();\n}\n",
13 "js/wix/faq-types.js": "// Declarations of faq-types.ts: what this file exports, their arguments and what they return.\n// export interface FaqCategory {\n// id: string;\n// /** Category title as the owner typed it (the API has no description or visibility on a category). */\n// title: string;\n// /** The API's sortOrder — lower first; null sorts after the numbered ones, stable by title. */\n// sortOrder: number | null;\n// /** Filled by the core from the loaded questions — hide empty categories with it. */\n// questionCount: number;\n// }\n// export interface FaqQuestion {\n// id: string;\n// /** The API's readonly slug, generated from the question text (\"\" when absent). The anchor id is `q-<slug || id>`. */\n// slug: string;\n// question: string;\n// /** Sanitized HTML for the answer body — render with set:html / dangerouslySetInnerHTML on a wrapper you control. */\n// answerHtml: string;\n// /** Plain text of the answer — search, meta descriptions, JSON-LD. */\n// answerText: string;\n// categoryId: string;\n// /** Order within the category — lower first; null sorts last. */\n// sortOrder: number | null;\n// /** Wix's own share link (points at the Wix-hosted FAQ widget, \"\" when the query did not ask for it). Our deep link is `faqDeepLink()`. */\n// shareLink: string;\n// /** Label titles — not shown to visitors; grouping for chatbots and integrations. */\n// labels: string[];\n// }\n// /** One category with its questions in display order. */\n// export interface FaqSection {\n// category: FaqCategory;\n// questions: FaqQuestion[];\n// }\n// /** Everything the FAQ page renders from — the unit the SSR fetch hands the islands. */\n// export interface FaqData {\n// categories: FaqCategory[];\n// questions: FaqQuestion[];\n// /** True when the loader hit MAX_QUESTIONS before the site's last page (more questions exist). */\n// truncated: boolean;\n// }\n// /** One cursor page of questions — the paged path for a very large FAQ. */\n// export interface FaqQuestionPage {\n// questions: FaqQuestion[];\n// /** Pass back as `cursor` for the next page; null → no more. */\n// nextCursor: string | null;\n// }\n// export interface FetchQuestionsOptions {\n// /** First page only — the cursor carries the filter on later pages. */\n// categoryId?: string | null;\n// /** Page size (default and maximum 100 — the API's CursorPaging.limit cap). */\n// limit?: number;\n// /** `nextCursor` from a previous page. */\n// cursor?: string | null;\n// }\n// FAQ DTOs — the serializable shapes every hook, component, and page consumes. Plain JSON: safe as\n// Astro island props or across server/client boundaries. Answers arrive as HTML the core produced\n// from the API's rich content (sanitized: text escaped, only http(s) links, no scripts or styles),\n// plus a plain-text twin for search, snippets, and structured data. No dates: the API's\n// _createdDate/_updatedDate are not visitor-facing and are dropped in the data layer.\nexport {};\n",
14 "js/wix/faq.js": "// Declarations of faq.ts: what this file exports, their arguments and what they return.\n// import { FAQ_APP_ID } from \"./faq-core.js\";\n// import type { FaqCategory, FaqData, FaqQuestion, FaqQuestionPage, FetchQuestionsOptions } from \"./faq-types.js\";\n// export { FAQ_APP_ID };\n// export declare function fetchCategories(): Promise<FaqCategory[]>;\n// export declare function fetchQuestions(o?: FetchQuestionsOptions): Promise<FaqQuestionPage>;\n// export declare function fetchFaq(): Promise<FaqData>;\n// export declare function fetchQuestionById(id: string): Promise<FaqQuestion | null>;\n// FAQ reads over REST — the twin of app/wix/faq/faq.ts. Same exports, same DTOs; the rules and mappers\n// come from faq-core (the SAME file the SDK transport uses, deployed flat next to this one by\n// deploy.mjs --stack static), so this file is only the transport: one fetch with a literal body per\n// function. Both endpoints are the FAQ app's READ scope; whether an anonymous VISITOR token may call\n// them is not documented — the reference shows an elevated example — so a 403 here means the site's\n// read needs a server-side path, not that the body is wrong (INSTRUCTIONS.md, verification caveats).\n// Porting: keep the paths, keep the bodies, port the core once.\n// docs: https://dev.wix.com/docs/api-reference/business-management/faq-app/category-v2/query-categories.md\n// docs: https://dev.wix.com/docs/api-reference/business-management/faq-app/question-entry-v2/query-question-entries.md\nimport { wixRequest } from \"./client.js\";\nimport { imgSrc } from \"./media.js\";\nimport { FAQ_APP_ID, assembleFaq, categoriesQueryBody, loadAllQuestions, nextCursor, questionByIdBody, questionsQueryBody, sortCategories, toCategory, toQuestion, } from \"./faq-core.js\";\nexport { FAQ_APP_ID };\nconst CATEGORIES_QUERY = \"/faq/v2/categories/query\";\nconst QUESTIONS_QUERY = \"/faq/v2/question-entries/query\";\n/**\n * All categories in owner order, one page (a site has few). `questionCount` is 0 here — fetchFaq fills it.\n * POST /faq/v2/categories/query { query: { sort: [{ fieldName: \"sortOrder\", order: \"ASC\" }], cursorPaging: { limit: 100 } } }\n * → { categories: [{ id, title, sortOrder, revision, createdDate, updatedDate }], pagingMetadata }\n */\nexport async function fetchCategories() {\n const res = await wixRequest(CATEGORIES_QUERY, { body: categoriesQueryBody() });\n return sortCategories((res?.categories ?? []).map((c) => toCategory(c)));\n}\n/**\n * One cursor page of questions, optionally one category's; `nextCursor` continues the same query —\n * send it alone, the filter and sort ride inside it.\n * POST /faq/v2/question-entries/query { query: { filter?: { categoryId: { $eq } }, sort, cursorPaging: { limit } }, contentFormat: \"RICH_CONTENT\", fieldSet: [\"SHARE_LINKS\"] } — first page\n * POST /faq/v2/question-entries/query { query: { cursorPaging: { limit, cursor } }, contentFormat, fieldSet } — later pages\n * → { questionEntries: [{ id, question, richContent | plainText | draftjs, categoryId, sortOrder, slug, shareLink, labels }], pagingMetadata: { count, cursors: { next, prev }, hasNext } }\n */\nexport async function fetchQuestions(o = {}) {\n const res = await wixRequest(QUESTIONS_QUERY, { body: questionsQueryBody(o) });\n return {\n questions: (res?.questionEntries ?? []).map((r) => toQuestion(r, imgSrc)),\n nextCursor: nextCursor(res?.pagingMetadata),\n };\n}\n/** Everything the FAQ page needs: categories (counted) + every question (paged until done or MAX_QUESTIONS). */\nexport async function fetchFaq() {\n const [cats, { questions, truncated }] = await Promise.all([fetchCategories(), loadAllQuestions(fetchQuestions)]);\n return assembleFaq(cats, questions, truncated);\n}\n/**\n * One question by id — the same query read as the page, so one permission covers both. Null when missing.\n * POST /faq/v2/question-entries/query { query: { filter: { _id: { $eq } }, cursorPaging: { limit: 1 } }, contentFormat, fieldSet }\n */\nexport async function fetchQuestionById(id) {\n const res = await wixRequest(QUESTIONS_QUERY, { body: questionByIdBody(id) });\n const raw = res?.questionEntries?.[0];\n return raw ? toQuestion(raw, imgSrc) : null;\n}\n"