akanjs 2.3.13-rc.3 → 2.4.0-rc.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.
@@ -16,6 +16,20 @@ export type PageSafeAreaConfig =
16
16
  bottom?: boolean;
17
17
  android?: "auto" | "edge-to-edge" | "none";
18
18
  };
19
+ /**
20
+ * Server-render strategy for the initial full-document SSR pass.
21
+ * - `"stream"` (default): flush the shell — including any `Loading` Suspense
22
+ * fallback — as soon as it is ready, then stream the resolved page. Redirects
23
+ * decided in the shell still become real HTTP redirects; redirects thrown
24
+ * inside suspended content degrade to soft (client) redirects.
25
+ * - `"block"`: buffer the whole document until every Suspense boundary resolves
26
+ * before sending a byte. The `Loading` fallback never reaches the browser, but
27
+ * a non-redirect error thrown in slow content can still yield a clean error
28
+ * page. Use only for routes that need that guarantee and do not care about SEO
29
+ * or first-paint of the fallback.
30
+ */
31
+ export type SsrRenderMode = "stream" | "block";
32
+
19
33
  /** Per-page CSR configuration for transition, safe-area, and gesture behavior. */
20
34
  export interface PageConfig {
21
35
  transition?: TransitionType;
@@ -26,6 +40,8 @@ export interface PageConfig {
26
40
  bottomInset?: number | boolean;
27
41
  gesture?: boolean;
28
42
  cache?: boolean;
43
+ /** Initial full-document SSR strategy. Defaults to `"stream"`. */
44
+ ssr?: SsrRenderMode;
29
45
  /**
30
46
  * Opt in to guarded RSC page suffix commits when the page does not require
31
47
  * head/metadata updates and the retained route chain head is invariant for
@@ -44,6 +60,7 @@ export interface CsrState {
44
60
  bottomInset: number;
45
61
  gesture: boolean;
46
62
  cache: boolean;
63
+ ssr: SsrRenderMode;
47
64
  topSafeAreaColor?: string;
48
65
  bottomSafeAreaColor?: string;
49
66
  }
@@ -99,6 +116,9 @@ export interface RouteRender {
99
116
  render: LayoutRender | PageRender;
100
117
  isAsync?: boolean;
101
118
  Loading?: LayoutLoadingRender | PageLoadingRender;
119
+ /** Loads the module and populates `Loading` without running `render`/`resolveHead`.
120
+ * Used by the patch (suffix) compose path, which never calls `resolveHead`. */
121
+ resolveLoading?: () => void | Promise<void>;
102
122
  NotFound?: LayoutNotFoundRender;
103
123
  Error?: LayoutErrorRender;
104
124
  resolveNotFound?: () => PromiseOrObject<LayoutNotFoundRender | undefined>;
@@ -242,6 +262,7 @@ export const defaultPageState: PageState = {
242
262
  bottomInset: 0,
243
263
  gesture: true,
244
264
  cache: false,
265
+ ssr: "stream",
245
266
  topSafeAreaColor: "var(--color-base-100, Canvas)",
246
267
  bottomSafeAreaColor: "var(--color-base-100, Canvas)",
247
268
  };
@@ -1,4 +1,4 @@
1
- import type { PageConfig, PageSafeAreaConfig, PageState, TransitionType } from "./csrTypes";
1
+ import type { PageConfig, PageSafeAreaConfig, PageState, SsrRenderMode, TransitionType } from "./csrTypes";
2
2
 
3
3
  export type DevicePlatform = "ios" | "android" | "web" | (string & {});
4
4
  export type SafeAreaInsets = { top: number; bottom: number };
@@ -19,11 +19,13 @@ const pageConfigKeys = new Set<keyof PageConfig>([
19
19
  "bottomInset",
20
20
  "gesture",
21
21
  "cache",
22
+ "ssr",
22
23
  "rscPatchHeadSafe",
23
24
  "topSafeAreaColor",
24
25
  "bottomSafeAreaColor",
25
26
  ]);
26
27
  const transitionTypes = new Set<TransitionType>(["none", "fade", "bottomUp", "stack", "scaleOut"]);
28
+ const ssrRenderModes = new Set<SsrRenderMode>(["stream", "block"]);
27
29
  const DEFAULT_BOOLEAN_INSET = 48;
28
30
 
29
31
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -43,6 +45,9 @@ export function validatePageConfig(routeKey: string, config?: PageConfig) {
43
45
  if (pageConfig.transition !== undefined && !transitionTypes.has(pageConfig.transition)) {
44
46
  throw new Error(`[route-convention] unsupported pageConfig.transition "${pageConfig.transition}" in ${routeKey}`);
45
47
  }
48
+ if (pageConfig.ssr !== undefined && !ssrRenderModes.has(pageConfig.ssr)) {
49
+ throw new Error(`[route-convention] unsupported pageConfig.ssr "${pageConfig.ssr}" in ${routeKey}`);
50
+ }
46
51
  if (pageConfig.topInset !== undefined && !isValidInsetValue(pageConfig.topInset)) {
47
52
  throw new Error(
48
53
  `[route-convention] pageConfig.topInset in ${routeKey} must be a boolean or non-negative px number.`,
@@ -141,6 +146,7 @@ export function resolvePageState({
141
146
  ? false
142
147
  : (config.gesture ?? false),
143
148
  cache: config.cache ?? false,
149
+ ssr: config.ssr ?? "stream",
144
150
  topSafeAreaColor: config.topSafeAreaColor ?? "var(--color-base-100, Canvas)",
145
151
  bottomSafeAreaColor: config.bottomSafeAreaColor ?? "var(--color-base-100, Canvas)",
146
152
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.3.13-rc.3",
3
+ "version": "2.4.0-rc.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -195,6 +195,7 @@
195
195
  "bullmq": "^5.76.10",
196
196
  "capacitor-plugin-safe-area": "^5.0.0",
197
197
  "chance": "^1.1.13",
198
+ "cordova-plugin-purchase": "^13.16.0",
198
199
  "croner": "^10.0.1",
199
200
  "daisyui": "5.5.23",
200
201
  "firebase": "^12.13.0",
@@ -279,6 +280,9 @@
279
280
  "chance": {
280
281
  "optional": true
281
282
  },
283
+ "cordova-plugin-purchase": {
284
+ "optional": true
285
+ },
282
286
  "croner": {
283
287
  "optional": true
284
288
  },
@@ -2,14 +2,14 @@ import type {
2
2
  Head,
3
3
  LayoutErrorRender,
4
4
  LayoutFallbackRoute,
5
- PageConfig,
6
5
  LayoutNotFoundRender,
6
+ PageConfig,
7
7
  PathRoute,
8
8
  ResolvedHead,
9
9
  RouteRender,
10
10
  } from "akanjs/client";
11
- import { getExplicitPageConfigKeys, resolvePageState } from "../client/frameConfig";
12
11
  import { Children, cloneElement, isValidElement, type ReactElement, type ReactNode, Suspense } from "react";
12
+ import { getExplicitPageConfigKeys, resolvePageState } from "../client/frameConfig";
13
13
  import { resolveHeadResult } from "./metadata";
14
14
  import { type AkanRouteSegmentState, createAkanRouteSegments, createAkanSegmentOutletKey } from "./routeState";
15
15
  import { isAkanRscPartialCommitEnabled } from "./rscPartialCommit";
@@ -61,16 +61,19 @@ export class RouteElementComposer {
61
61
  pathRoute,
62
62
  params,
63
63
  searchParams,
64
+ navKey,
64
65
  }: {
65
66
  pathRoute: PathRoute;
66
67
  params: Record<string, string>;
67
68
  searchParams: Record<string, string | string[]>;
69
+ navKey?: string;
68
70
  }): ReactNode {
69
71
  return RouteElementComposer.composeRenders({
70
72
  renders: RouteElementComposer.#getRenderStack(pathRoute),
71
73
  segments: isAkanRscPartialCommitEnabled() ? createAkanRouteSegments(pathRoute) : undefined,
72
74
  params,
73
75
  searchParams,
76
+ navKey,
74
77
  });
75
78
  }
76
79
 
@@ -79,11 +82,13 @@ export class RouteElementComposer {
79
82
  params,
80
83
  searchParams,
81
84
  patchStartIndex,
85
+ navKey,
82
86
  }: {
83
87
  pathRoute: PathRoute;
84
88
  params: Record<string, string>;
85
89
  searchParams: Record<string, string | string[]>;
86
90
  patchStartIndex: number;
91
+ navKey?: string;
87
92
  }): ReactNode | null {
88
93
  const renders = RouteElementComposer.#getRenderStack(pathRoute);
89
94
  if (!Number.isInteger(patchStartIndex) || patchStartIndex < 0 || patchStartIndex >= renders.length) return null;
@@ -91,9 +96,18 @@ export class RouteElementComposer {
91
96
  renders: renders.slice(patchStartIndex),
92
97
  params,
93
98
  searchParams,
99
+ navKey,
94
100
  });
95
101
  }
96
102
 
103
+ static async resolveSuffixLoadings(pathRoute: PathRoute, patchStartIndex: number): Promise<void> {
104
+ const renders = RouteElementComposer.#getRenderStack(pathRoute).slice(Math.max(patchStartIndex, 0));
105
+
106
+ await Promise.all(
107
+ renders.map((routeRender) => Promise.resolve(routeRender?.resolveLoading?.()).catch(() => undefined)),
108
+ );
109
+ }
110
+
97
111
  static async resolveHead({
98
112
  pathRoute,
99
113
  params,
@@ -170,18 +184,23 @@ export class RouteElementComposer {
170
184
  segments,
171
185
  params,
172
186
  searchParams,
187
+ navKey,
173
188
  }: {
174
189
  renders: RouteRender[];
175
190
  segments?: AkanRouteSegmentState[];
176
191
  params: Record<string, string>;
177
192
  searchParams: Record<string, string | string[]>;
193
+ navKey?: string;
178
194
  }): ReactNode {
179
195
  let element: ReactNode = null;
180
196
  for (let i = renders.length - 1; i >= 0; i--) {
181
197
  const routeRender = renders[i];
182
198
  if (!routeRender) continue;
199
+ const loadingFallback = RouteElementComposer.#composeLoadingFallback(renders.slice(i), params);
200
+ const suspenseKey =
201
+ navKey && loadingFallback != null && i === renders.length - 1 ? `akan-loading:${navKey}` : undefined;
183
202
  element = (
184
- <Suspense fallback={RouteElementComposer.#composeLoadingFallback(renders.slice(i), params)}>
203
+ <Suspense key={suspenseKey} fallback={loadingFallback}>
185
204
  <RouteElementComposer.AsyncRender routeRender={routeRender} params={params} searchParams={searchParams}>
186
205
  {element}
187
206
  </RouteElementComposer.AsyncRender>
@@ -34,6 +34,7 @@ export const defaultPageState: PageState = {
34
34
  bottomInset: 0,
35
35
  gesture: true,
36
36
  cache: false,
37
+ ssr: "stream",
37
38
  topSafeAreaColor: "transparent",
38
39
  bottomSafeAreaColor: "transparent",
39
40
  };
@@ -341,6 +342,10 @@ export class RouteTreeBuilder {
341
342
  const loadModule = RouteTreeBuilder.#makeLazyModule(key, kind, loader);
342
343
  const routeRender: RouteRender = {
343
344
  isAsync: true,
345
+ resolveLoading: async () => {
346
+ const mod = await loadModule();
347
+ routeRender.Loading = mod.Loading as never;
348
+ },
344
349
  render: async (props: LayoutProps | PageProps) => {
345
350
  const mod = await loadModule();
346
351
  routeRender.Loading = mod.Loading as never;
@@ -2,6 +2,7 @@ import type {
2
2
  AkanNotFoundError,
3
3
  AkanRedirectError,
4
4
  LayoutFallbackRoute,
5
+ PageState,
5
6
  PathRoute,
6
7
  RedirectStatus,
7
8
  ResolvedHead,
@@ -9,6 +10,7 @@ import type {
9
10
  import { type AkanI18nConfig, DEFAULT_AKAN_I18N, getBasePathFromPathname, Logger } from "akanjs/common";
10
11
  import {
11
12
  getRequestDynamicUsage,
13
+ getRequestFrameState,
12
14
  getRequestPolicy,
13
15
  getRequestTheme,
14
16
  requestStorage,
@@ -525,6 +527,7 @@ export class RscRenderer {
525
527
  patchStartSegment: undefined,
526
528
  patchHeadSafe: undefined,
527
529
  patchHeadSnapshot: undefined,
530
+ ssrBlocking: cached.ssrBlocking,
528
531
  },
529
532
  send: (message) => this.#send(message),
530
533
  isCancelled: () => this.#cancelledRenderRequests.has(requestId),
@@ -553,9 +556,11 @@ export class RscRenderer {
553
556
  else element = await this.#renderNotFound(urlObj);
554
557
  const traceCacheKey =
555
558
  effectivePatchDecision.status === "patch" ? (patchCacheEntry?.key ?? cacheEntry?.key) : cacheEntry?.key;
559
+ const ssrBlocking = getRequestFrameState<PageState>()?.ssr === "block";
556
560
  const trace: RscTraceMetadata = {
557
561
  ...createTraceBase(effectivePatchDecision, traceCacheKey),
558
562
  cache: cacheEntry ? "miss" : "bypass",
563
+ ssrBlocking,
559
564
  };
560
565
  this.#logger.verbose(`render[${requestId}] starting Flight stream`);
561
566
  const result = await this.#renderFlightElement(element, msg.clientManifest ?? this.#clientManifest, {
@@ -597,6 +602,7 @@ export class RscRenderer {
597
602
  routeId,
598
603
  tags: cacheState.tags,
599
604
  theme: getRequestTheme(),
605
+ ssrBlocking,
600
606
  cacheState,
601
607
  patch: createCachedRscPatchMetadata({
602
608
  targetRouterState,
@@ -616,6 +622,7 @@ export class RscRenderer {
616
622
  routeId,
617
623
  tags: cacheState.tags,
618
624
  theme: getRequestTheme(),
625
+ ssrBlocking,
619
626
  cacheState,
620
627
  },
621
628
  storeTtl,
@@ -1243,6 +1250,7 @@ export class RscRenderer {
1243
1250
  pathRoute,
1244
1251
  params: match.params,
1245
1252
  searchParams,
1253
+ navKey: url.pathname + url.search,
1246
1254
  });
1247
1255
  return (
1248
1256
  <html
@@ -1283,11 +1291,14 @@ export class RscRenderer {
1283
1291
  basePath: this.#getBasePath(url),
1284
1292
  });
1285
1293
  setRequestFrameState(pathRoute.pageState);
1294
+
1295
+ await RouteElementComposer.resolveSuffixLoadings(pathRoute, patchStartIndex);
1286
1296
  return RouteElementComposer.composeSuffix({
1287
1297
  pathRoute,
1288
1298
  params: match.params,
1289
1299
  searchParams,
1290
1300
  patchStartIndex,
1301
+ navKey: url.pathname + url.search,
1291
1302
  });
1292
1303
  }
1293
1304
 
@@ -16,6 +16,8 @@ export interface CachedRscResult {
16
16
  routeId?: string;
17
17
  tags?: string[];
18
18
  theme?: string;
19
+ /** Whether the matched route opted into blocking SSR (`pageConfig.ssr: "block"`). */
20
+ ssrBlocking?: boolean;
19
21
  cacheState: RouteCacheRenderState;
20
22
  patch?: CachedRscPatchMetadata;
21
23
  }
@@ -594,12 +594,13 @@ export class SsrFromRscRenderer {
594
594
  ? `${SsrFromRscRenderer.#clientBootstrap}\n${input.extraBootstrapInline}`
595
595
  : SsrFromRscRenderer.#clientBootstrap;
596
596
 
597
+ const waitForAllReady = input.waitForAllReady || process.env.AKAN_SSR_WAIT_FOR_ALL_READY === "1";
597
598
  const renderHtml = async () => {
598
599
  const root = await thenable;
599
600
  const stream = await renderToReadableStream(root, {
600
601
  bootstrapScriptContent: bootstrap,
601
602
  });
602
- await stream.allReady;
603
+ if (waitForAllReady) await stream.allReady;
603
604
  return stream;
604
605
  };
605
606
  const requestContext = input.requestStore ?? input.request;
@@ -42,6 +42,13 @@ export interface RscTraceMetadata {
42
42
  patchHeadSafe?: boolean;
43
43
  patchHeadSnapshot?: string;
44
44
  routeState?: string;
45
+ /**
46
+ * Resolved from the matched route's `pageConfig.ssr === "block"`. Rides the
47
+ * trace so the host (which never resolves pageConfig itself) can decide
48
+ * whether the full-document SSR pass buffers until every Suspense boundary
49
+ * resolves. Absent/`false` means stream the shell first.
50
+ */
51
+ ssrBlocking?: boolean;
45
52
  }
46
53
 
47
54
  export interface SsrFromRscInput {
@@ -73,4 +80,11 @@ export interface SsrFromRscInput {
73
80
  injectThemeInitScript?: boolean;
74
81
  lateControl?: Promise<SsrLateRedirect | null>;
75
82
  onCancel?: (reason?: unknown) => void;
83
+ /**
84
+ * When true, buffer the whole document until every Suspense boundary resolves
85
+ * (`stream.allReady`) before emitting a byte, matching `pageConfig.ssr:
86
+ * "block"`. Defaults to shell-first streaming so `Loading` fallbacks surface.
87
+ * The `AKAN_SSR_WAIT_FOR_ALL_READY=1` env var forces blocking globally.
88
+ */
89
+ waitForAllReady?: boolean;
76
90
  }
@@ -578,6 +578,7 @@ export class WebRouter {
578
578
  importmap: this.#artifact.vendorMap,
579
579
  theme: themeCookieExists ? undefined : (rscResult.theme ?? "system"),
580
580
  lateControl: rscResult.lateControl,
581
+ waitForAllReady: rscResult.trace?.ssrBlocking ?? false,
581
582
  onCancel: (reason: unknown) => {
582
583
  rscResult.cancel(reason);
583
584
  },
package/store/baseSt.ts CHANGED
@@ -44,6 +44,7 @@ export class BaseStore extends store("base" as const, () => ({
44
44
  bottomInset: 0,
45
45
  gesture: true,
46
46
  cache: false,
47
+ ssr: "stream",
47
48
  topSafeAreaColor: "var(--color-base-100, Canvas)",
48
49
  bottomSafeAreaColor: "var(--color-base-100, Canvas)",
49
50
  } as PageState,
@@ -10,6 +10,19 @@ export type PageSafeAreaConfig = boolean | "top" | "bottom" | {
10
10
  bottom?: boolean;
11
11
  android?: "auto" | "edge-to-edge" | "none";
12
12
  };
13
+ /**
14
+ * Server-render strategy for the initial full-document SSR pass.
15
+ * - `"stream"` (default): flush the shell — including any `Loading` Suspense
16
+ * fallback — as soon as it is ready, then stream the resolved page. Redirects
17
+ * decided in the shell still become real HTTP redirects; redirects thrown
18
+ * inside suspended content degrade to soft (client) redirects.
19
+ * - `"block"`: buffer the whole document until every Suspense boundary resolves
20
+ * before sending a byte. The `Loading` fallback never reaches the browser, but
21
+ * a non-redirect error thrown in slow content can still yield a clean error
22
+ * page. Use only for routes that need that guarantee and do not care about SEO
23
+ * or first-paint of the fallback.
24
+ */
25
+ export type SsrRenderMode = "stream" | "block";
13
26
  /** Per-page CSR configuration for transition, safe-area, and gesture behavior. */
14
27
  export interface PageConfig {
15
28
  transition?: TransitionType;
@@ -20,6 +33,8 @@ export interface PageConfig {
20
33
  bottomInset?: number | boolean;
21
34
  gesture?: boolean;
22
35
  cache?: boolean;
36
+ /** Initial full-document SSR strategy. Defaults to `"stream"`. */
37
+ ssr?: SsrRenderMode;
23
38
  /**
24
39
  * Opt in to guarded RSC page suffix commits when the page does not require
25
40
  * head/metadata updates and the retained route chain head is invariant for
@@ -37,6 +52,7 @@ export interface CsrState {
37
52
  bottomInset: number;
38
53
  gesture: boolean;
39
54
  cache: boolean;
55
+ ssr: SsrRenderMode;
40
56
  topSafeAreaColor?: string;
41
57
  bottomSafeAreaColor?: string;
42
58
  }
@@ -98,6 +114,9 @@ export interface RouteRender {
98
114
  render: LayoutRender | PageRender;
99
115
  isAsync?: boolean;
100
116
  Loading?: LayoutLoadingRender | PageLoadingRender;
117
+ /** Loads the module and populates `Loading` without running `render`/`resolveHead`.
118
+ * Used by the patch (suffix) compose path, which never calls `resolveHead`. */
119
+ resolveLoading?: () => void | Promise<void>;
101
120
  NotFound?: LayoutNotFoundRender;
102
121
  Error?: LayoutErrorRender;
103
122
  resolveNotFound?: () => PromiseOrObject<LayoutNotFoundRender | undefined>;
@@ -11,17 +11,20 @@ export declare class RouteElementComposer {
11
11
  route: PathRoute | LayoutFallbackRoute;
12
12
  basePath?: string | null;
13
13
  }): Promise<import("akanjs/client").PageState>;
14
- static compose({ pathRoute, params, searchParams, }: {
14
+ static compose({ pathRoute, params, searchParams, navKey, }: {
15
15
  pathRoute: PathRoute;
16
16
  params: Record<string, string>;
17
17
  searchParams: Record<string, string | string[]>;
18
+ navKey?: string;
18
19
  }): ReactNode;
19
- static composeSuffix({ pathRoute, params, searchParams, patchStartIndex, }: {
20
+ static composeSuffix({ pathRoute, params, searchParams, patchStartIndex, navKey, }: {
20
21
  pathRoute: PathRoute;
21
22
  params: Record<string, string>;
22
23
  searchParams: Record<string, string | string[]>;
23
24
  patchStartIndex: number;
25
+ navKey?: string;
24
26
  }): ReactNode | null;
27
+ static resolveSuffixLoadings(pathRoute: PathRoute, patchStartIndex: number): Promise<void>;
25
28
  static resolveHead({ pathRoute, params, searchParams, }: {
26
29
  pathRoute: PathRoute;
27
30
  params: Record<string, string>;
@@ -41,11 +44,12 @@ export declare class RouteElementComposer {
41
44
  error?: unknown;
42
45
  digest?: string;
43
46
  }): Promise<ReactNode | null>;
44
- static composeRenders({ renders, segments, params, searchParams, }: {
47
+ static composeRenders({ renders, segments, params, searchParams, navKey, }: {
45
48
  renders: RouteRender[];
46
49
  segments?: AkanRouteSegmentState[];
47
50
  params: Record<string, string>;
48
51
  searchParams: Record<string, string | string[]>;
52
+ navKey?: string;
49
53
  }): ReactNode;
50
54
  static renderAsync({ routeRender, children, params, searchParams, }: {
51
55
  routeRender: RouteRender;
@@ -8,6 +8,8 @@ export interface CachedRscResult {
8
8
  routeId?: string;
9
9
  tags?: string[];
10
10
  theme?: string;
11
+ /** Whether the matched route opted into blocking SSR (`pageConfig.ssr: "block"`). */
12
+ ssrBlocking?: boolean;
11
13
  cacheState: RouteCacheRenderState;
12
14
  patch?: CachedRscPatchMetadata;
13
15
  }
@@ -40,6 +40,13 @@ export interface RscTraceMetadata {
40
40
  patchHeadSafe?: boolean;
41
41
  patchHeadSnapshot?: string;
42
42
  routeState?: string;
43
+ /**
44
+ * Resolved from the matched route's `pageConfig.ssr === "block"`. Rides the
45
+ * trace so the host (which never resolves pageConfig itself) can decide
46
+ * whether the full-document SSR pass buffers until every Suspense boundary
47
+ * resolves. Absent/`false` means stream the shell first.
48
+ */
49
+ ssrBlocking?: boolean;
43
50
  }
44
51
  export interface SsrFromRscInput {
45
52
  request?: Request;
@@ -70,4 +77,11 @@ export interface SsrFromRscInput {
70
77
  injectThemeInitScript?: boolean;
71
78
  lateControl?: Promise<SsrLateRedirect | null>;
72
79
  onCancel?: (reason?: unknown) => void;
80
+ /**
81
+ * When true, buffer the whole document until every Suspense boundary resolves
82
+ * (`stream.allReady`) before emitting a byte, matching `pageConfig.ssr:
83
+ * "block"`. Defaults to shell-first streaming so `Loading` fallbacks surface.
84
+ * The `AKAN_SSR_WAIT_FOR_ALL_READY=1` env var forces blocking globally.
85
+ */
86
+ waitForAllReady?: boolean;
73
87
  }
@@ -0,0 +1,10 @@
1
+ import type { ClientView } from "akanjs/fetch";
2
+ interface LoadViewProps<T extends string, Model extends {
3
+ id: string;
4
+ }> {
5
+ view: ClientView<T, Model>;
6
+ }
7
+ export default function LoadView<T extends string, Light extends {
8
+ id: string;
9
+ }>({ view }: LoadViewProps<T, Light>): import("react/jsx-runtime").JSX.Element;
10
+ export {};
@@ -8,6 +8,7 @@ export declare const Model: {
8
8
  Remove: typeof import("./Remove.d.ts").default;
9
9
  RemoveWrapper: typeof import("./RemoveWrapper.d.ts").default;
10
10
  LoadInit: typeof import("./LoadInit.d.ts").default;
11
+ LoadView: typeof import("./LoadView.d.ts").default;
11
12
  ViewWrapper: typeof import("./ViewWrapper.d.ts").default;
12
13
  ViewEditModal: typeof import("./ViewEditModal.d.ts").default;
13
14
  Edit: typeof import("./Edit.d.ts").default;
@@ -7,6 +7,7 @@ export declare const NewWrapper: typeof import("./NewWrapper.d.ts").default;
7
7
  export declare const EditWrapper: typeof import("./EditWrapper.d.ts").default;
8
8
  export declare const RemoveWrapper: typeof import("./RemoveWrapper.d.ts").default;
9
9
  export declare const LoadInit: typeof import("./LoadInit.d.ts").default;
10
+ export declare const LoadView: typeof import("./LoadView.d.ts").default;
10
11
  export declare const ViewWrapper: typeof import("./ViewWrapper.d.ts").default;
11
12
  export declare const ViewEditModal: typeof import("./ViewEditModal.d.ts").default;
12
13
  export declare const Edit: typeof import("./Edit.d.ts").default;
@@ -3,9 +3,13 @@ export { createRobotPage } from "./createRobotPage.d.ts";
3
3
  export { createSitemapPage } from "./createSitemapPage.d.ts";
4
4
  export { lazy } from "./lazy.d.ts";
5
5
  export type * from "./types.d.ts";
6
+ export { useCamera } from "./useCamera.d.ts";
7
+ export { useCodepush } from "./useCodepush.d.ts";
8
+ export { useContact } from "./useContact.d.ts";
6
9
  export { useCsrValues } from "./useCsrValues.d.ts";
7
10
  export { useDebounce } from "./useDebounce.d.ts";
8
11
  export { useFetch, useFetchFn } from "./useFetch.d.ts";
12
+ export { useGeoLocation } from "./useGeoLocation.d.ts";
9
13
  export { useHistory } from "./useHistory.d.ts";
10
14
  export { useInterval } from "./useInterval.d.ts";
11
15
  export { useLocation } from "./useLocation.d.ts";
@@ -0,0 +1,19 @@
1
+ import { type CapacitorPermissionState } from "akanjs/client/capacitor";
2
+ type PermissionStatus = {
3
+ camera: CapacitorPermissionState;
4
+ photos: CapacitorPermissionState;
5
+ };
6
+ /** Capacitor camera/photos hook with permission checks and app-settings fallback. */
7
+ export declare const useCamera: () => {
8
+ permissions: PermissionStatus;
9
+ getPhoto: (src?: "prompt" | "camera" | "photos") => Promise<{
10
+ [key: string]: unknown;
11
+ dataUrl?: string;
12
+ } | undefined>;
13
+ pickImage: () => Promise<{
14
+ [key: string]: unknown;
15
+ photos: unknown[];
16
+ }>;
17
+ checkPermission: (type: "photos" | "camera" | "all") => Promise<void>;
18
+ };
19
+ export {};
@@ -0,0 +1,16 @@
1
+ import type { ProtoAppInfo, ProtoFile } from "akanjs/constant";
2
+ export declare const useCodepush: ({ serverUrl }: {
3
+ serverUrl: string;
4
+ }) => {
5
+ update: boolean;
6
+ version: string;
7
+ initialize: () => Promise<void>;
8
+ checkNewRelease: () => Promise<{
9
+ release: ProtoAppInfo & {
10
+ appBuild: string;
11
+ };
12
+ bundleFile: ProtoFile;
13
+ } | undefined>;
14
+ codepush: () => Promise<void>;
15
+ statManager: () => Promise<void>;
16
+ };
@@ -0,0 +1,11 @@
1
+ import { type CapacitorPermissionState } from "akanjs/client/capacitor";
2
+ type PermissionStatus = {
3
+ contacts: CapacitorPermissionState;
4
+ };
5
+ /** Capacitor contacts hook with permission checks and contact loading helpers. */
6
+ export declare const useContact: () => {
7
+ permissions: PermissionStatus;
8
+ getContacts: () => Promise<unknown[]>;
9
+ checkPermission: () => Promise<void>;
10
+ };
11
+ export {};
@@ -0,0 +1,8 @@
1
+ /** Capacitor geolocation hook with permission checks and current position lookup. */
2
+ export declare const useGeoLocation: () => {
3
+ checkPermission: () => Promise<{
4
+ geolocation: string;
5
+ coarseLocation: string;
6
+ }>;
7
+ getPosition: () => Promise<unknown>;
8
+ };
@@ -0,0 +1,19 @@
1
+ import "cordova-plugin-purchase/www/store";
2
+ export type PlatformType = "android" | "ios" | "all";
3
+ export interface ProductType {
4
+ id: string;
5
+ type: keyof typeof CdvPurchase.ProductType;
6
+ }
7
+ export type CdvProductType = CdvPurchase.ProductType;
8
+ export declare const usePurchase: ({ platform, productInfo, url, onPay, onSubscribe, }: {
9
+ platform: PlatformType;
10
+ productInfo: ProductType[];
11
+ url: string;
12
+ onPay?: (transaction: CdvPurchase.Transaction) => void | Promise<void>;
13
+ onSubscribe?: (transaction: CdvPurchase.Transaction) => void | Promise<void>;
14
+ }) => {
15
+ isLoading: boolean;
16
+ products: CdvPurchase.Product[];
17
+ purchaseProduct: (product: CdvPurchase.Product) => Promise<void>;
18
+ restorePurchases: () => Promise<void>;
19
+ };
@@ -0,0 +1,11 @@
1
+ "use client";
2
+ import type { ClientView } from "akanjs/fetch";
3
+
4
+ import { Load } from "../Load";
5
+
6
+ interface LoadViewProps<T extends string, Model extends { id: string }> {
7
+ view: ClientView<T, Model>;
8
+ }
9
+ export default function LoadView<T extends string, Light extends { id: string }>({ view }: LoadViewProps<T, Light>) {
10
+ return <Load.View view={view} renderView={() => null} loading={null} />;
11
+ }
package/ui/Model/index.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  EditModal,
5
5
  EditWrapper,
6
6
  LoadInit,
7
+ LoadView,
7
8
  New,
8
9
  NewWrapper,
9
10
  Remove,
@@ -25,6 +26,7 @@ export const Model = {
25
26
  Remove,
26
27
  RemoveWrapper,
27
28
  LoadInit,
29
+ LoadView,
28
30
  ViewWrapper,
29
31
  ViewEditModal,
30
32
  Edit,
@@ -9,6 +9,7 @@ export const NewWrapper = lazy(() => import("./NewWrapper"));
9
9
  export const EditWrapper = lazy(() => import("./EditWrapper"));
10
10
  export const RemoveWrapper = lazy(() => import("./RemoveWrapper"));
11
11
  export const LoadInit = lazy(() => import("./LoadInit"));
12
+ export const LoadView = lazy(() => import("./LoadView"));
12
13
  export const ViewWrapper = lazy(() => import("./ViewWrapper"));
13
14
  export const ViewEditModal = lazy(() => import("./ViewEditModal"));
14
15
  export const Edit = lazy(() => import("./Edit"));
package/webkit/index.ts CHANGED
@@ -3,9 +3,13 @@ export { createRobotPage } from "./createRobotPage";
3
3
  export { createSitemapPage } from "./createSitemapPage";
4
4
  export { lazy } from "./lazy";
5
5
  export type * from "./types";
6
+ export { useCamera } from "./useCamera";
7
+ export { useCodepush } from "./useCodepush";
8
+ export { useContact } from "./useContact";
6
9
  export { useCsrValues } from "./useCsrValues";
7
10
  export { useDebounce } from "./useDebounce";
8
11
  export { useFetch, useFetchFn } from "./useFetch";
12
+ export { useGeoLocation } from "./useGeoLocation";
9
13
  export { useHistory } from "./useHistory";
10
14
  export { useInterval } from "./useInterval";
11
15
  export { useLocation } from "./useLocation";
@@ -0,0 +1,103 @@
1
+ "use client";
2
+ import { Device, isMobileDevice } from "akanjs/client";
3
+ import { type CapacitorPermissionState, loadCapacitorCamera } from "akanjs/client/capacitor";
4
+ import { useEffect, useState } from "react";
5
+
6
+ type PermissionStatus = {
7
+ camera: CapacitorPermissionState;
8
+ photos: CapacitorPermissionState;
9
+ };
10
+
11
+ /** Capacitor camera/photos hook with permission checks and app-settings fallback. */
12
+ export const useCamera = () => {
13
+ const [permissions, setPermissions] = useState<PermissionStatus>({ camera: "prompt", photos: "prompt" });
14
+
15
+ /**
16
+ * 최초로 킬 경우 권한은 prompt 상태이다.
17
+ * prompt 상태일 경우 권한을 요청한다.
18
+ * 권한이 denied 상태일 경우 설정으로 이동한다.
19
+ * 이후 state의 permission을 업데이트해야한다.
20
+ *
21
+ */
22
+ const checkPermission = async (type: "photos" | "camera" | "all") => {
23
+ try {
24
+ const { Camera } = await loadCapacitorCamera();
25
+ if (type === "photos") {
26
+ if (permissions.photos === "prompt") {
27
+ const { photos } = await Camera.requestPermissions();
28
+ setPermissions((prev) => ({ ...prev, photos }));
29
+ } else if (permissions.photos === "denied") {
30
+ location.assign("app-settings:");
31
+ return;
32
+ }
33
+ } else if (type === "camera") {
34
+ if (permissions.camera === "prompt") {
35
+ const { camera } = await Camera.requestPermissions();
36
+ setPermissions((prev) => ({ ...prev, camera }));
37
+ } else if (permissions.camera === "denied") {
38
+ location.assign("app-settings:");
39
+ return;
40
+ }
41
+ } else {
42
+ if (permissions.camera === "prompt" || permissions.photos === "prompt") {
43
+ const permissions = await Camera.requestPermissions();
44
+ setPermissions(permissions);
45
+ } else if (permissions.camera === "denied" || permissions.photos === "denied") {
46
+ location.assign("app-settings:");
47
+ return;
48
+ }
49
+ }
50
+ } catch {
51
+ }
52
+ };
53
+
54
+ const getPhoto = async (src: "prompt" | "camera" | "photos" = "prompt") => {
55
+ const { Camera, CameraResultType, CameraSource } = await loadCapacitorCamera();
56
+ const source =
57
+ Device.getDevice().info.platform !== "web"
58
+ ? src === "prompt"
59
+ ? CameraSource.Prompt
60
+ : src === "camera"
61
+ ? CameraSource.Camera
62
+ : CameraSource.Photos
63
+ : CameraSource.Photos;
64
+ const permission = src === "prompt" ? "all" : src === "camera" ? "camera" : "photos";
65
+ await checkPermission(permission);
66
+ try {
67
+ const photo = await Camera.getPhoto({
68
+ quality: 100,
69
+ source,
70
+ allowEditing: false,
71
+ resultType: CameraResultType.DataUrl,
72
+ promptLabelHeader: "프로필 사진을 올려주세요",
73
+ promptLabelPhoto: "앨범에서 선택하기",
74
+ promptLabelPicture: "사진 찍기",
75
+ promptLabelCancel: "취소",
76
+ });
77
+ return photo;
78
+ } catch (e) {
79
+ if (e === "User cancelled photos app") return;
80
+ }
81
+ };
82
+
83
+ const pickImage = async () => {
84
+ await checkPermission("photos");
85
+ const { Camera } = await loadCapacitorCamera();
86
+ const photo = await Camera.pickImages({
87
+ quality: 90,
88
+ });
89
+
90
+ return photo;
91
+ };
92
+
93
+ useEffect(() => {
94
+ void (async () => {
95
+ if (isMobileDevice()) {
96
+ const { Camera } = await loadCapacitorCamera();
97
+ const permissions = await Camera.checkPermissions();
98
+ setPermissions(permissions);
99
+ }
100
+ })();
101
+ }, []);
102
+ return { permissions, getPhoto, pickImage, checkPermission };
103
+ };
@@ -0,0 +1,99 @@
1
+ "use client";
2
+ import { loadCapacitorApp, loadCapacitorDevice, loadCapacitorUpdater } from "akanjs/client/capacitor";
3
+ import { HttpClient, mergeVersion, splitVersion } from "akanjs/common";
4
+ import type { ProtoAppInfo, ProtoFile } from "akanjs/constant";
5
+ import { useState } from "react";
6
+
7
+ export const useCodepush = ({ serverUrl }: { serverUrl: string }) => {
8
+ const [update, setUpdate] = useState(false);
9
+ const [version, setVersion] = useState("");
10
+
11
+ const initialize = async () => {
12
+ const { CapacitorUpdater } = await loadCapacitorUpdater();
13
+ await CapacitorUpdater.notifyAppReady();
14
+ };
15
+ const checkNewRelease = async () => {
16
+ const [{ App }, { Device }, { CapacitorUpdater }] = await Promise.all([
17
+ loadCapacitorApp(),
18
+ loadCapacitorDevice(),
19
+ loadCapacitorUpdater(),
20
+ ]);
21
+
22
+ const info = await Device.getInfo();
23
+ const app = await App.getInfo();
24
+ const pluginVersion = await CapacitorUpdater.getPluginVersion();
25
+ const { deviceId } = await CapacitorUpdater.getDeviceId();
26
+ const { bundle: version, native } = await CapacitorUpdater.current();
27
+ const builtInversion = await CapacitorUpdater.getBuiltinVersion();
28
+ const appId = app.id;
29
+ const platform = info.platform;
30
+
31
+ window.alert(
32
+ `getBuildinVersion:${builtInversion.version}\ncurrent.bundle:${version.version}\ncurrennt.native:${native}`,
33
+ );
34
+ /**
35
+ * "version_name": "builtin",
36
+ * "version_code": "1",
37
+ * "app_id": "com.lu.app",
38
+ * "plugin_version": "5.6.9",
39
+ * "version_build": "1.0",
40
+ * "is_prod": true,
41
+ * "version_os": "17.0.1",
42
+ * "is_emulator": true,
43
+ * "custom_id": "",
44
+ * "device_id": "C77000B1-7D28-4697-ADE0-74452F47C350",
45
+ * "platform": "ios",
46
+ * "defaultChannel": ""
47
+ */
48
+ const { major, minor, patch } = splitVersion(version.version === "builtin" ? app.version : version.version);
49
+ const appName = process.env.AKAN_PUBLIC_APP_NAME ?? "";
50
+
51
+ const appInfo: ProtoAppInfo = {
52
+ appId,
53
+ appName,
54
+ deviceId: deviceId,
55
+ platform: platform as "ios" | "android",
56
+ branch: process.env.AKAN_PUBLIC_ENV ?? "debug",
57
+ isEmulator: info.isVirtual,
58
+ major: parseInt(major),
59
+ minor: parseInt(minor),
60
+ patch: parseInt(patch),
61
+ buildNum: app.build, //앱내 빌드시 버전 횟수 모르면 고한테 물어보기
62
+ versionOs: info.osVersion,
63
+ };
64
+
65
+ const url = serverUrl.replace("lu", "akasys");
66
+ const httpClient = new HttpClient(url);
67
+ const release = await httpClient.post<(ProtoAppInfo & { appBuild: string }) | null>("/release/codepush", {
68
+ data: { ...appInfo },
69
+ });
70
+ if (!release) return;
71
+ const file = await httpClient.get<ProtoFile>(`/file/file/${release.appBuild}`);
72
+
73
+ return { release: release, bundleFile: file };
74
+ };
75
+
76
+ const codepush = async () => {
77
+
78
+ const newRelease = await checkNewRelease();
79
+ if (!newRelease) return;
80
+ const { release, bundleFile } = newRelease;
81
+ const { CapacitorUpdater } = await loadCapacitorUpdater();
82
+ setUpdate(true);
83
+ const bundle = await CapacitorUpdater.download({
84
+ url: bundleFile.url,
85
+ version: mergeVersion(release.major, release.minor, release.patch),
86
+ });
87
+
88
+ await CapacitorUpdater.set(bundle);
89
+ };
90
+ const getVersion = async () => {
91
+ const { CapacitorUpdater } = await loadCapacitorUpdater();
92
+ return await CapacitorUpdater.getBuiltinVersion();
93
+ };
94
+
95
+ const statManager = async () => {
96
+ };
97
+
98
+ return { update, version, initialize, checkNewRelease, codepush, statManager };
99
+ };
@@ -0,0 +1,52 @@
1
+ "use client";
2
+ import { Device } from "akanjs/client";
3
+ import { type CapacitorPermissionState, loadCapacitorContacts } from "akanjs/client/capacitor";
4
+ import { useEffect, useState } from "react";
5
+
6
+ type PermissionStatus = {
7
+ contacts: CapacitorPermissionState;
8
+ };
9
+
10
+ /** Capacitor contacts hook with permission checks and contact loading helpers. */
11
+ export const useContact = () => {
12
+ const [permissions, setPermissions] = useState<PermissionStatus>({ contacts: "prompt" });
13
+
14
+ /**
15
+ * 최초로 킬 경우 권한은 prompt 상태이다.
16
+ * prompt 상태일 경우 권한을 요청한다.
17
+ * 권한이 denied 상태일 경우 설정으로 이동한다.
18
+ * 이후 state의 permission을 업데이트해야한다.
19
+ *
20
+ */
21
+ const checkPermission = async () => {
22
+ try {
23
+ const { Contacts } = await loadCapacitorContacts();
24
+ if (permissions.contacts === "prompt") {
25
+ const { contacts } = await Contacts.requestPermissions();
26
+ setPermissions((prev) => ({ ...prev, contacts }));
27
+ } else if (permissions.contacts === "denied") {
28
+ location.assign("app-settings:");
29
+ return;
30
+ }
31
+ } catch {
32
+ }
33
+ };
34
+
35
+ const getContacts = async () => {
36
+ await checkPermission();
37
+ const { Contacts } = await loadCapacitorContacts();
38
+ const { contacts } = await Contacts.getContacts({ projection: { name: true, phones: true } });
39
+ return contacts;
40
+ };
41
+
42
+ useEffect(() => {
43
+ void (async () => {
44
+ if (Device.getDevice().info.platform === "web") return;
45
+ const { Contacts } = await loadCapacitorContacts();
46
+ const permissions = await Contacts.checkPermissions();
47
+ setPermissions(permissions);
48
+ })();
49
+ }, []);
50
+
51
+ return { permissions, getContacts, checkPermission };
52
+ };
@@ -0,0 +1,24 @@
1
+ "use client";
2
+ import { loadCapacitorGeolocation } from "akanjs/client/capacitor";
3
+
4
+ /** Capacitor geolocation hook with permission checks and current position lookup. */
5
+ export const useGeoLocation = () => {
6
+ const checkPermission = async (): Promise<{ geolocation: string; coarseLocation: string }> => {
7
+ const { Geolocation } = await loadCapacitorGeolocation();
8
+ const { location: geolocation, coarseLocation } = await Geolocation.requestPermissions();
9
+ return { geolocation, coarseLocation };
10
+ };
11
+
12
+ const getPosition = async () => {
13
+ const { geolocation, coarseLocation } = await checkPermission();
14
+ if (geolocation === "denied" || coarseLocation === "denied") {
15
+ location.assign("app-settings:");
16
+ return;
17
+ }
18
+ const { Geolocation } = await loadCapacitorGeolocation();
19
+ const coordinates = await Geolocation.getCurrentPosition();
20
+ return coordinates;
21
+ };
22
+
23
+ return { checkPermission, getPosition };
24
+ };
@@ -0,0 +1,156 @@
1
+ "use client";
2
+
3
+ import "cordova-plugin-purchase/www/store";
4
+
5
+ import { loadCapacitorApp } from "akanjs/client/capacitor";
6
+ import { useEffect, useRef, useState } from "react";
7
+
8
+ export type PlatformType = "android" | "ios" | "all";
9
+ export interface ProductType {
10
+ id: string;
11
+ type: keyof typeof CdvPurchase.ProductType;
12
+ }
13
+
14
+ export type CdvProductType = CdvPurchase.ProductType;
15
+
16
+ export const usePurchase = ({
17
+ platform,
18
+ productInfo,
19
+ url,
20
+ onPay,
21
+ onSubscribe,
22
+ }: {
23
+ platform: PlatformType;
24
+ productInfo: ProductType[];
25
+ url: string;
26
+ onPay?: (transaction: CdvPurchase.Transaction) => void | Promise<void>;
27
+ onSubscribe?: (transaction: CdvPurchase.Transaction) => void | Promise<void>;
28
+ }) => {
29
+ const [isLoading, setIsLoading] = useState(true);
30
+ const billingRef = useRef<any>(null);
31
+
32
+ useEffect(() => {
33
+ const init = async () => {
34
+ if (CdvPurchase.store.isReady) {
35
+ setIsLoading(false);
36
+
37
+ return;
38
+ }
39
+ const { App } = await loadCapacitorApp();
40
+ const app = await App.getInfo();
41
+ if (platform === "all")
42
+ CdvPurchase.store.register([
43
+ ...productInfo.map((prouct) => ({
44
+ id: prouct.id,
45
+ platform: CdvPurchase.Platform.GOOGLE_PLAY,
46
+ type: CdvPurchase.ProductType[prouct.type],
47
+ })),
48
+ ...productInfo.map((prouct) => ({
49
+ id: prouct.id,
50
+ platform: CdvPurchase.Platform.APPLE_APPSTORE,
51
+ type: CdvPurchase.ProductType[prouct.type],
52
+ })),
53
+ ]);
54
+ else
55
+ CdvPurchase.store.register(
56
+ productInfo.map((product) => ({
57
+ id: product.id,
58
+ platform: platform === "android" ? CdvPurchase.Platform.GOOGLE_PLAY : CdvPurchase.Platform.APPLE_APPSTORE,
59
+ type: CdvPurchase.ProductType[product.type],
60
+ })),
61
+ );
62
+
63
+ await CdvPurchase.store.initialize([
64
+ { platform: CdvPurchase.Platform.APPLE_APPSTORE, options: { needAppReceipt: false } },
65
+ { platform: CdvPurchase.Platform.GOOGLE_PLAY },
66
+ ]);
67
+ await CdvPurchase.store.update();
68
+ await CdvPurchase.store.restorePurchases();
69
+
70
+ CdvPurchase.store.validator = (async (
71
+ request: { id: string; transaction: { id: string; purchaseToken: string; appStoreReceipt: string } },
72
+ callback: (result: {
73
+ ok: boolean;
74
+ data: { id: string; latest_receipt: boolean; transaction: CdvPurchase.Transaction };
75
+ }) => void,
76
+ ) => {
77
+ const transactionId = request.transaction.id;
78
+ const transactions = CdvPurchase.store.localTransactions;
79
+ const verifingTransaction = transactions.find((transaction) => transaction.transactionId === transactionId);
80
+
81
+ if (verifingTransaction?.state !== "approved") return;
82
+
83
+ const billing = await fetch(`${url}/billing/verifyBilling`, {
84
+ method: "POST",
85
+ headers: {
86
+ "Content-Type": "application/json",
87
+ },
88
+ body: JSON.stringify({
89
+ data: {
90
+ platform: verifingTransaction.platform === CdvPurchase.Platform.GOOGLE_PLAY ? "google" : "apple",
91
+ packageName: app.id,
92
+ productId: verifingTransaction.products[0].id,
93
+ receipt:
94
+ verifingTransaction.platform === CdvPurchase.Platform.GOOGLE_PLAY
95
+ ? request.transaction.purchaseToken
96
+ : request.transaction.appStoreReceipt,
97
+ transactionId: verifingTransaction.transactionId,
98
+ },
99
+ }),
100
+ });
101
+
102
+ billingRef.current = billing.json();
103
+
104
+ callback({
105
+
106
+ ok: !!billing,
107
+
108
+ data: { id: request.id, latest_receipt: true, transaction: request.transaction } as any,
109
+ });
110
+ }) as any;
111
+ if (CdvPurchase.store.localReceipts.length > 0) {
112
+ CdvPurchase.store.localReceipts.forEach((receipt) => {
113
+ if (receipt.platform === CdvPurchase.Platform.GOOGLE_PLAY)
114
+ if (receipt.transactions[0].state === CdvPurchase.TransactionState.APPROVED)
115
+ void receipt.transactions[0].verify();
116
+ else void receipt.transactions[0].finish();
117
+ });
118
+ }
119
+
120
+ CdvPurchase.store
121
+ .when()
122
+ .approved((transaction) => {
123
+ void transaction.verify();
124
+ })
125
+ .verified((receipt) => {
126
+ void receipt.finish();
127
+ })
128
+ .finished((transaction) => {
129
+ void inAppPurchase(transaction);
130
+ });
131
+ setIsLoading(false);
132
+ };
133
+ void init();
134
+ }, []);
135
+ const purchaseProduct = async (product: CdvPurchase.Product) => {
136
+ await product.getOffer()?.order();
137
+ };
138
+
139
+ const restorePurchases = async () => {
140
+ await CdvPurchase.store.restorePurchases();
141
+ };
142
+
143
+ const inAppPurchase = async (transaction: CdvPurchase.Transaction) => {
144
+ const product = CdvPurchase.store.get(transaction.products[0].id);
145
+ if (product?.type === "consumable") await onPay?.(transaction);
146
+ else await onSubscribe?.(transaction);
147
+ await transaction.finish();
148
+ };
149
+
150
+ return {
151
+ isLoading,
152
+ products: CdvPurchase.store.products,
153
+ purchaseProduct,
154
+ restorePurchases,
155
+ };
156
+ };