@hook-sdk/template 0.10.0 → 0.12.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/dist/index.d.cts CHANGED
@@ -68,6 +68,7 @@ type AppConfig = {
68
68
  onboarding?: OnboardingConfig;
69
69
  deepLinks?: DeepLinks;
70
70
  features_enabled?: string[];
71
+ theme?: Record<string, unknown>;
71
72
  };
72
73
 
73
74
  interface AuthScreenProps {
@@ -117,6 +118,16 @@ interface PushPromptTexts {
117
118
  iosInstallCta?: string;
118
119
  deniedTitle: string;
119
120
  deniedBody: string;
121
+ /**
122
+ * Audit Wave 3 — Fix #33: per-platform recovery copy shown when permission
123
+ * is denied. All four are optional; if the matching platform's copy is
124
+ * missing the recovery paragraph is omitted (back-compat for callers that
125
+ * haven't supplied them yet).
126
+ */
127
+ deniedRecoveryIos?: string;
128
+ deniedRecoveryAndroid?: string;
129
+ deniedRecoveryDesktop?: string;
130
+ deniedRecoveryInApp?: string;
120
131
  unsupportedBody: string;
121
132
  }
122
133
  interface PushPromptProps {
@@ -160,7 +171,9 @@ interface State {
160
171
  declare class ErrorBoundary extends Component<Props, State> {
161
172
  state: State;
162
173
  static getDerivedStateFromError(error: Error): State;
163
- componentDidCatch(error: Error): void;
174
+ componentDidCatch(error: Error, info: {
175
+ componentStack: string;
176
+ }): void;
164
177
  render(): string | number | boolean | Iterable<ReactNode> | react_jsx_runtime.JSX.Element;
165
178
  }
166
179
 
@@ -531,6 +544,8 @@ type PushUiState = {
531
544
  kind: 'subscribed';
532
545
  } | {
533
546
  kind: 'denied';
547
+ } | {
548
+ kind: 'dismissed';
534
549
  } | {
535
550
  kind: 'error';
536
551
  code: string;
@@ -540,6 +555,7 @@ declare function usePush(): {
540
555
  state: PushUiState;
541
556
  subscribe: () => Promise<void>;
542
557
  unsubscribe: () => Promise<void>;
558
+ dismiss: () => void;
543
559
  };
544
560
 
545
561
  declare function useReminders(): {
@@ -557,7 +573,7 @@ declare function useReminders(): {
557
573
  sendAt: string | Date;
558
574
  title: string;
559
575
  body: string;
560
- url?: string;
576
+ url: string;
561
577
  }>) => Promise<{
562
578
  accepted: number;
563
579
  rejected: number;
@@ -569,7 +585,7 @@ declare function useReminders(): {
569
585
  slot: string;
570
586
  title: string;
571
587
  body: string;
572
- url?: string;
588
+ url: string;
573
589
  }>) => Promise<void>>[0]) => Promise<void>;
574
590
  };
575
591
 
@@ -717,7 +733,7 @@ type OnboardingFlowProps = {
717
733
  onComplete: (value: Record<string, unknown>) => void;
718
734
  persistKey: string;
719
735
  };
720
- declare function OnboardingFlow({ steps, screens, onComplete, persistKey, }: OnboardingFlowProps): react_jsx_runtime.JSX.Element;
736
+ declare function OnboardingFlow({ steps, screens, onComplete, persistKey, }: OnboardingFlowProps): react_jsx_runtime.JSX.Element | null;
721
737
 
722
738
  type OnboardingStepCtx = {
723
739
  stepIndex: number;
@@ -815,6 +831,7 @@ declare const AppConfigSchema: z.ZodObject<{
815
831
  emailVerify: z.ZodOptional<z.ZodString>;
816
832
  }, z.core.$strict>>;
817
833
  features_enabled: z.ZodOptional<z.ZodArray<z.ZodString>>;
834
+ theme: z.ZodOptional<z.ZodObject<{}, z.core.$loose>>;
818
835
  }, z.core.$strict>;
819
836
  declare function parseAppConfig(input: unknown): AppConfig;
820
837
 
package/dist/index.d.ts CHANGED
@@ -68,6 +68,7 @@ type AppConfig = {
68
68
  onboarding?: OnboardingConfig;
69
69
  deepLinks?: DeepLinks;
70
70
  features_enabled?: string[];
71
+ theme?: Record<string, unknown>;
71
72
  };
72
73
 
73
74
  interface AuthScreenProps {
@@ -117,6 +118,16 @@ interface PushPromptTexts {
117
118
  iosInstallCta?: string;
118
119
  deniedTitle: string;
119
120
  deniedBody: string;
121
+ /**
122
+ * Audit Wave 3 — Fix #33: per-platform recovery copy shown when permission
123
+ * is denied. All four are optional; if the matching platform's copy is
124
+ * missing the recovery paragraph is omitted (back-compat for callers that
125
+ * haven't supplied them yet).
126
+ */
127
+ deniedRecoveryIos?: string;
128
+ deniedRecoveryAndroid?: string;
129
+ deniedRecoveryDesktop?: string;
130
+ deniedRecoveryInApp?: string;
120
131
  unsupportedBody: string;
121
132
  }
122
133
  interface PushPromptProps {
@@ -160,7 +171,9 @@ interface State {
160
171
  declare class ErrorBoundary extends Component<Props, State> {
161
172
  state: State;
162
173
  static getDerivedStateFromError(error: Error): State;
163
- componentDidCatch(error: Error): void;
174
+ componentDidCatch(error: Error, info: {
175
+ componentStack: string;
176
+ }): void;
164
177
  render(): string | number | boolean | Iterable<ReactNode> | react_jsx_runtime.JSX.Element;
165
178
  }
166
179
 
@@ -531,6 +544,8 @@ type PushUiState = {
531
544
  kind: 'subscribed';
532
545
  } | {
533
546
  kind: 'denied';
547
+ } | {
548
+ kind: 'dismissed';
534
549
  } | {
535
550
  kind: 'error';
536
551
  code: string;
@@ -540,6 +555,7 @@ declare function usePush(): {
540
555
  state: PushUiState;
541
556
  subscribe: () => Promise<void>;
542
557
  unsubscribe: () => Promise<void>;
558
+ dismiss: () => void;
543
559
  };
544
560
 
545
561
  declare function useReminders(): {
@@ -557,7 +573,7 @@ declare function useReminders(): {
557
573
  sendAt: string | Date;
558
574
  title: string;
559
575
  body: string;
560
- url?: string;
576
+ url: string;
561
577
  }>) => Promise<{
562
578
  accepted: number;
563
579
  rejected: number;
@@ -569,7 +585,7 @@ declare function useReminders(): {
569
585
  slot: string;
570
586
  title: string;
571
587
  body: string;
572
- url?: string;
588
+ url: string;
573
589
  }>) => Promise<void>>[0]) => Promise<void>;
574
590
  };
575
591
 
@@ -717,7 +733,7 @@ type OnboardingFlowProps = {
717
733
  onComplete: (value: Record<string, unknown>) => void;
718
734
  persistKey: string;
719
735
  };
720
- declare function OnboardingFlow({ steps, screens, onComplete, persistKey, }: OnboardingFlowProps): react_jsx_runtime.JSX.Element;
736
+ declare function OnboardingFlow({ steps, screens, onComplete, persistKey, }: OnboardingFlowProps): react_jsx_runtime.JSX.Element | null;
721
737
 
722
738
  type OnboardingStepCtx = {
723
739
  stepIndex: number;
@@ -815,6 +831,7 @@ declare const AppConfigSchema: z.ZodObject<{
815
831
  emailVerify: z.ZodOptional<z.ZodString>;
816
832
  }, z.core.$strict>>;
817
833
  features_enabled: z.ZodOptional<z.ZodArray<z.ZodString>>;
834
+ theme: z.ZodOptional<z.ZodObject<{}, z.core.$loose>>;
818
835
  }, z.core.$strict>;
819
836
  declare function parseAppConfig(input: unknown): AppConfig;
820
837
 
package/dist/index.js CHANGED
@@ -83,7 +83,12 @@ var AppConfigSchema = z.object({
83
83
  persistedKeys: z.array(PersistedKeySchema),
84
84
  onboarding: OnboardingSchema.optional(),
85
85
  deepLinks: DeepLinksSchema.optional(),
86
- features_enabled: z.array(z.string()).optional()
86
+ features_enabled: z.array(z.string()).optional(),
87
+ // Build-time injected theme metadata (e.g. icon_url for InstallSplash).
88
+ // Apps don't author this directly; deploy workflows fill it from
89
+ // env-resolved bundle host. Permissive shape so apps/workflows can
90
+ // extend without re-bumping the template schema.
91
+ theme: z.object({}).passthrough().optional()
87
92
  }).strict();
88
93
  function parseAppConfig(input) {
89
94
  const r = AppConfigSchema.safeParse(input);
@@ -1759,8 +1764,15 @@ var ErrorBoundary = class extends Component {
1759
1764
  static getDerivedStateFromError(error) {
1760
1765
  return { error };
1761
1766
  }
1762
- componentDidCatch(error) {
1763
- console.error("[ErrorBoundary] caught", error);
1767
+ componentDidCatch(error, info) {
1768
+ console.error(
1769
+ "[ErrorBoundary] caught:",
1770
+ error?.message || "(no message)",
1771
+ "\nstack:",
1772
+ error?.stack || "(no stack)",
1773
+ "\ncomponentStack:",
1774
+ info?.componentStack || "(no componentStack)"
1775
+ );
1764
1776
  }
1765
1777
  render() {
1766
1778
  if (this.state.error) {
@@ -1988,6 +2000,8 @@ function FallbackPaywall() {
1988
2000
  // src/hooks/usePush.ts
1989
2001
  import { useCallback as useCallback4, useEffect as useEffect6, useState as useState5 } from "react";
1990
2002
  import { useHook as useHook5 } from "@hook-sdk/sdk";
2003
+ var DISMISS_STORAGE_KEY = "push:dismissed-until";
2004
+ var DISMISS_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
1991
2005
  function detectIosNeedsInstall() {
1992
2006
  if (typeof navigator === "undefined" || typeof window === "undefined") return false;
1993
2007
  const ua = navigator.userAgent || "";
@@ -1998,6 +2012,21 @@ function detectIosNeedsInstall() {
1998
2012
  const legacyStandalone = typeof navigator.standalone === "boolean" ? navigator.standalone : false;
1999
2013
  return !(standalone || legacyStandalone);
2000
2014
  }
2015
+ function readDismissedUntil() {
2016
+ if (typeof localStorage === "undefined") return null;
2017
+ try {
2018
+ const raw = localStorage.getItem(DISMISS_STORAGE_KEY);
2019
+ if (raw === null) return null;
2020
+ const n = Number.parseInt(raw, 10);
2021
+ return Number.isFinite(n) ? n : null;
2022
+ } catch {
2023
+ return null;
2024
+ }
2025
+ }
2026
+ function isDismissedNow() {
2027
+ const until = readDismissedUntil();
2028
+ return until !== null && until > Date.now();
2029
+ }
2001
2030
  function deriveState(push) {
2002
2031
  if (!push.isAvailable()) {
2003
2032
  if (detectIosNeedsInstall()) return { kind: "ios_needs_install" };
@@ -2010,6 +2039,7 @@ function deriveState(push) {
2010
2039
  if (detectIosNeedsInstall()) return { kind: "ios_needs_install" };
2011
2040
  return { kind: "unsupported" };
2012
2041
  }
2042
+ if (isDismissedNow()) return { kind: "dismissed" };
2013
2043
  return { kind: "prompt" };
2014
2044
  }
2015
2045
  function usePush() {
@@ -2039,14 +2069,41 @@ function usePush() {
2039
2069
  throw e;
2040
2070
  }
2041
2071
  }, [push]);
2042
- return { state, subscribe, unsubscribe };
2072
+ const dismiss = useCallback4(() => {
2073
+ if (typeof localStorage !== "undefined") {
2074
+ try {
2075
+ localStorage.setItem(DISMISS_STORAGE_KEY, String(Date.now() + DISMISS_TTL_MS));
2076
+ } catch {
2077
+ }
2078
+ }
2079
+ setState({ kind: "dismissed" });
2080
+ }, []);
2081
+ return { state, subscribe, unsubscribe, dismiss };
2043
2082
  }
2044
2083
 
2045
2084
  // src/components/PushPrompt.tsx
2046
2085
  import { jsx as jsx19, jsxs as jsxs14 } from "react/jsx-runtime";
2086
+ function platformRecoveryCopy(texts) {
2087
+ if (typeof navigator === "undefined") return null;
2088
+ const ua = navigator.userAgent || "";
2089
+ const platform = detectPlatform(ua);
2090
+ switch (platform) {
2091
+ case "ios-safari":
2092
+ case "ios-other":
2093
+ return texts.deniedRecoveryIos ?? null;
2094
+ case "android":
2095
+ return texts.deniedRecoveryAndroid ?? null;
2096
+ case "desktop":
2097
+ return texts.deniedRecoveryDesktop ?? null;
2098
+ case "in-app":
2099
+ return texts.deniedRecoveryInApp ?? null;
2100
+ default:
2101
+ return null;
2102
+ }
2103
+ }
2047
2104
  function PushPrompt2({ texts, onSubscribed, onDeclined, onInstallRequested, className }) {
2048
2105
  const { state, subscribe } = usePush();
2049
- if (state.kind === "subscribed") return null;
2106
+ if (state.kind === "subscribed" || state.kind === "dismissed") return null;
2050
2107
  if (state.kind === "ios_needs_install") {
2051
2108
  return /* @__PURE__ */ jsxs14("div", { className, role: "region", "aria-label": texts.iosInstallTitle, children: [
2052
2109
  /* @__PURE__ */ jsx19("h3", { children: texts.iosInstallTitle }),
@@ -2055,9 +2112,11 @@ function PushPrompt2({ texts, onSubscribed, onDeclined, onInstallRequested, clas
2055
2112
  ] });
2056
2113
  }
2057
2114
  if (state.kind === "denied") {
2115
+ const recovery = platformRecoveryCopy(texts);
2058
2116
  return /* @__PURE__ */ jsxs14("div", { className, role: "region", "aria-label": texts.deniedTitle, children: [
2059
2117
  /* @__PURE__ */ jsx19("h3", { children: texts.deniedTitle }),
2060
- /* @__PURE__ */ jsx19("p", { children: texts.deniedBody })
2118
+ /* @__PURE__ */ jsx19("p", { children: texts.deniedBody }),
2119
+ recovery && /* @__PURE__ */ jsx19("p", { "data-testid": "denied-recovery", children: recovery })
2061
2120
  ] });
2062
2121
  }
2063
2122
  if (state.kind === "unsupported") {
@@ -2515,7 +2574,7 @@ function PreAuthShell({
2515
2574
  }
2516
2575
 
2517
2576
  // src/OnboardingFlow.tsx
2518
- import { useCallback as useCallback11, useMemo as useMemo8, useRef as useRef3, useState as useState12 } from "react";
2577
+ import { useCallback as useCallback11, useMemo as useMemo8, useRef as useRef3 } from "react";
2519
2578
  import { usePersistedState } from "@hook-sdk/sdk";
2520
2579
 
2521
2580
  // src/hooks/useOnboardingStep.ts
@@ -2534,62 +2593,82 @@ function useOnboardingStep() {
2534
2593
  // src/OnboardingFlow.tsx
2535
2594
  import { jsx as jsx24 } from "react/jsx-runtime";
2536
2595
  var isFilled = (v) => v != null && v !== "";
2596
+ var CURRENT_STEP_FIELD = "currentStep";
2597
+ function readPersistedStepIdx(draft) {
2598
+ const raw = draft[CURRENT_STEP_FIELD];
2599
+ return typeof raw === "number" && Number.isFinite(raw) && raw >= 0 ? raw : 0;
2600
+ }
2537
2601
  function OnboardingFlow({
2538
2602
  steps,
2539
2603
  screens,
2540
2604
  onComplete,
2541
2605
  persistKey
2542
2606
  }) {
2543
- const [draft, setDraft] = usePersistedState(persistKey, {});
2544
- const [idx, setIdx] = useState12(0);
2607
+ const [draft, setDraft, status] = usePersistedState(persistKey, {});
2545
2608
  const draftRef = useRef3(draft);
2546
2609
  draftRef.current = draft;
2547
- const step = steps[idx];
2548
- if (!step) {
2549
- throw new Error(
2550
- `[hook-template] OnboardingFlow: step index ${idx} out of range (steps.length=${steps.length})`
2551
- );
2552
- }
2553
- const Screen = screens[step.screen];
2554
- if (!Screen) {
2555
- throw new Error(
2556
- `[hook-template] OnboardingFlow: missing screen component for step '${step.id}' (expected key '${step.screen}' in screens prop)`
2557
- );
2558
- }
2559
- const valid = useMemo8(
2560
- () => (step.validates ?? []).every((field) => isFilled(draft[field])),
2561
- [draft, step]
2610
+ const idx = readPersistedStepIdx(draft);
2611
+ const clampedIdx = Math.min(Math.max(idx, 0), Math.max(steps.length - 1, 0));
2612
+ const setIdx = useCallback11(
2613
+ (n) => {
2614
+ setDraft((prev) => {
2615
+ const prevIdx = readPersistedStepIdx(prev);
2616
+ const nextIdx = typeof n === "function" ? n(prevIdx) : n;
2617
+ return { ...prev, [CURRENT_STEP_FIELD]: nextIdx };
2618
+ });
2619
+ },
2620
+ [setDraft]
2562
2621
  );
2563
2622
  const setValue = useCallback11(
2564
2623
  (patch) => {
2565
2624
  draftRef.current = { ...draftRef.current, ...patch };
2566
- setDraft((prev2) => ({ ...prev2, ...patch }));
2625
+ setDraft((prev) => ({ ...prev, ...patch }));
2567
2626
  },
2568
2627
  [setDraft]
2569
2628
  );
2629
+ const step = steps[clampedIdx];
2630
+ const valid = useMemo8(
2631
+ () => step ? (step.validates ?? []).every((field) => isFilled(draft[field])) : false,
2632
+ [draft, step]
2633
+ );
2570
2634
  const next = useCallback11(() => {
2635
+ if (!step) return;
2571
2636
  const current = draftRef.current;
2572
2637
  const validNow = (step.validates ?? []).every((field) => isFilled(current[field]));
2573
2638
  if (!validNow) return;
2574
- if (idx + 1 >= steps.length) {
2639
+ if (clampedIdx + 1 >= steps.length) {
2575
2640
  onComplete(current);
2576
2641
  } else {
2577
- setIdx(idx + 1);
2642
+ setIdx(clampedIdx + 1);
2578
2643
  }
2579
- }, [idx, onComplete, step, steps.length]);
2580
- const prev = useCallback11(() => setIdx((i) => Math.max(0, i - 1)), []);
2644
+ }, [clampedIdx, onComplete, step, steps.length, setIdx]);
2645
+ const prevStep = useCallback11(() => setIdx((i) => Math.max(0, i - 1)), [setIdx]);
2581
2646
  const ctx = useMemo8(
2582
2647
  () => ({
2583
- stepIndex: idx,
2648
+ stepIndex: clampedIdx,
2584
2649
  totalSteps: steps.length,
2585
2650
  value: draft,
2586
2651
  setValue,
2587
2652
  valid,
2588
2653
  next,
2589
- prev
2654
+ prev: prevStep
2590
2655
  }),
2591
- [idx, steps.length, draft, setValue, valid, next, prev]
2656
+ [clampedIdx, steps.length, draft, setValue, valid, next, prevStep]
2592
2657
  );
2658
+ if (status.loading) {
2659
+ return null;
2660
+ }
2661
+ if (!step) {
2662
+ throw new Error(
2663
+ `[hook-template] OnboardingFlow: step index ${clampedIdx} out of range (steps.length=${steps.length})`
2664
+ );
2665
+ }
2666
+ const Screen = screens[step.screen];
2667
+ if (!Screen) {
2668
+ throw new Error(
2669
+ `[hook-template] OnboardingFlow: missing screen component for step '${step.id}' (expected key '${step.screen}' in screens prop)`
2670
+ );
2671
+ }
2593
2672
  return /* @__PURE__ */ jsx24(OnboardingStepContext.Provider, { value: ctx, children: /* @__PURE__ */ jsx24(Screen, {}) });
2594
2673
  }
2595
2674