@leisuresaas/react-native 0.6.2

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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/package.json +49 -0
  4. package/src/ads/ad-slot.tsx +131 -0
  5. package/src/ads/ad.tsx +91 -0
  6. package/src/ads/bundle-id.ts +9 -0
  7. package/src/ads/constants.ts +92 -0
  8. package/src/ads/context.tsx +76 -0
  9. package/src/ads/creative.tsx +105 -0
  10. package/src/ads/default-theme.ts +284 -0
  11. package/src/ads/hooks.ts +156 -0
  12. package/src/ads/index.ts +32 -0
  13. package/src/ads/layouts/image.tsx +101 -0
  14. package/src/ads/layouts/index.tsx +28 -0
  15. package/src/ads/layouts/shared.tsx +80 -0
  16. package/src/ads/layouts/text.tsx +77 -0
  17. package/src/ads/lineup-id.ts +6 -0
  18. package/src/ads/open-click.ts +61 -0
  19. package/src/ads/rotation/indicators-context.tsx +31 -0
  20. package/src/ads/rotation/indicators.tsx +22 -0
  21. package/src/ads/rotation/shell.tsx +98 -0
  22. package/src/ads/session-id.ts +18 -0
  23. package/src/ads/theme.ts +105 -0
  24. package/src/ads-ui.tsx +40 -0
  25. package/src/ads.ts +16 -0
  26. package/src/app-update/context.tsx +380 -0
  27. package/src/auth-errors.ts +50 -0
  28. package/src/auth-session.ts +129 -0
  29. package/src/auth-storage.ts +62 -0
  30. package/src/auth.tsx +249 -0
  31. package/src/dev.ts +16 -0
  32. package/src/drive/types.ts +62 -0
  33. package/src/drive.ts +355 -0
  34. package/src/errors.ts +11 -0
  35. package/src/gateway-url.ts +7 -0
  36. package/src/hosted-ui-link.ts +265 -0
  37. package/src/index.ts +161 -0
  38. package/src/platform-api-base.ts +72 -0
  39. package/src/platform.ts +16 -0
  40. package/src/public-ads.ts +105 -0
  41. package/src/public-app-config.ts +106 -0
  42. package/src/public-auth.ts +252 -0
  43. package/src/publishable-key.ts +19 -0
  44. package/src/push.ts +89 -0
  45. package/src/types.ts +123 -0
  46. package/src/userinfo.ts +34 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) LeisureSaaS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @leisuresaas/react-native
2
+
3
+ React Native SDK for LeisureSaas **frontend-only** surfaces:
4
+
5
+ - OAuth / Hosted UI / Magic login
6
+ - Public Ads & App Config (`pk_live_` → `/v1/public/*`)
7
+ - Drive User Plane (`pk_live_` + Bearer → `/v1/user/storage/*`)
8
+ - Local push helpers (`buildEnablePushRegistration` — **no HTTP**)
9
+
10
+ **Does not** ship Integration Key (`ik_`) clients or product BFF path constants.
11
+ Business APIs (billing, entitlement, device-token register) → **your BFF** + [`sdk/go`](../go).
12
+
13
+ **AI**:见 **[AGENTS.md](AGENTS.md)** · 架构:[plan/services/sdk-react-native/ARCHITECTURE.md](../../plan/services/sdk-react-native/ARCHITECTURE.md)
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @leisuresaas/react-native
19
+ ```
20
+
21
+ `metro.config.js`:`transpilePackages: ["@leisuresaas/react-native"]`。
22
+ Peers:`expo`, `expo-auth-session`, `expo-secure-store`, `expo-web-browser`, `react`, `react-native`;Push 可选 `expo-notifications`。
23
+
24
+ ## Quick start
25
+
26
+ ```tsx
27
+ import {
28
+ AuthProvider,
29
+ AdsProvider,
30
+ AppUpdateProvider,
31
+ buildEnablePushRegistration,
32
+ useAuth,
33
+ } from "@leisuresaas/react-native";
34
+
35
+ <AuthProvider config={{ issuer, clientId, redirectScheme: "myapp", terminal: "mobile" }}>
36
+ <AdsProvider
37
+ publishableKey={process.env.EXPO_PUBLIC_PUBLISHABLE_KEY!}
38
+ gatewayUrl={process.env.EXPO_PUBLIC_GATEWAY_URL!}
39
+ >
40
+ <AppUpdateProvider
41
+ publishableKey={process.env.EXPO_PUBLIC_PUBLISHABLE_KEY!}
42
+ gatewayUrl={process.env.EXPO_PUBLIC_GATEWAY_URL!}
43
+ >
44
+ <App />
45
+ </AppUpdateProvider>
46
+ </AdsProvider>
47
+ </AuthProvider>
48
+ ```
49
+
50
+ 站内广告:素材 `link_url` 填 `app:/billing`(可带 query)。点击仍走 `click_url`;在 `AdsProvider` 上传 `onInAppNavigate={(path) => router.push(path)}`。未传时站内点击不会打开浏览器。
51
+
52
+ ### Push(产品 BFF 登记)
53
+
54
+ ```ts
55
+ const reg = await buildEnablePushRegistration();
56
+ await apiFetch(accessToken, "/v1/notifications/device-tokens", {
57
+ method: "POST",
58
+ body: JSON.stringify({
59
+ platform: reg.platform,
60
+ token: reg.token,
61
+ android_package: reg.androidPackage,
62
+ bundle_id: reg.bundleId,
63
+ environment: reg.environment,
64
+ }),
65
+ });
66
+ // 注销:DELETE /v1/notifications/device-tokens/{token}(产品路径)
67
+ ```
68
+
69
+ 需要 Dev Client / Store build + 原生 FCM/APNs(不要 Expo Go / `ExponentPushToken`)。
70
+
71
+ ## Env
72
+
73
+ | Variable | Use |
74
+ |----------|-----|
75
+ | `EXPO_PUBLIC_OAUTH_ISSUER` | OAuth / Hosted UI only |
76
+ | `EXPO_PUBLIC_GATEWAY_URL` | Public Ads / App Config / Drive |
77
+ | `EXPO_PUBLIC_PUBLISHABLE_KEY` | `pk_live_` |
78
+
79
+ **Never** put Integration Keys in `EXPO_PUBLIC_*`.
80
+
81
+ ## Breaking 0.5 → 0.6
82
+
83
+ - Removed `LeisureSaasClient` / `createLeisureSaasClient` (bff + gateway/`ik_` modes)
84
+ - `AdsProvider` no longer takes `client`
85
+ - No SDK HTTP for push register — use `buildEnablePushRegistration` + product API
86
+ - Platform paths: `/v1/public/*`, `/v1/user/*` (not `/api/v1/…`)
87
+
88
+ ## License
89
+
90
+ MIT
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@leisuresaas/react-native",
3
+ "version": "0.6.2",
4
+ "description": "React Native SDK for LeisureSaas: OAuth, Public Ads/App Config, Drive User Plane (publishable keys). Frontend-only — no Integration Key.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/leisuresaas/react-native.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/leisuresaas/react-native/issues"
12
+ },
13
+ "homepage": "https://github.com/leisuresaas/react-native#readme",
14
+ "main": "src/index.ts",
15
+ "types": "src/index.ts",
16
+ "sideEffects": false,
17
+ "files": [
18
+ "src",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public",
24
+ "registry": "https://registry.npmjs.org"
25
+ },
26
+ "scripts": {
27
+ "typecheck": "tsc --noEmit",
28
+ "prepublishOnly": "npm run typecheck"
29
+ },
30
+ "peerDependencies": {
31
+ "expo": ">=52.0.0",
32
+ "expo-auth-session": ">=6.0.0",
33
+ "expo-linking": ">=7.0.0",
34
+ "expo-notifications": ">=0.29.0",
35
+ "expo-secure-store": ">=14.0.0",
36
+ "expo-web-browser": ">=14.0.0",
37
+ "react": ">=18.0.0",
38
+ "react-native": ">=0.76.0"
39
+ },
40
+ "peerDependenciesMeta": {
41
+ "expo-notifications": {
42
+ "optional": true
43
+ }
44
+ },
45
+ "devDependencies": {
46
+ "@types/react": "~19.2.2",
47
+ "typescript": "~5.9.2"
48
+ }
49
+ }
@@ -0,0 +1,131 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import { Text, View } from "react-native";
3
+
4
+ import type { AdFeedItem, AdsFeedResponse } from "../types";
5
+ import { normalizeAdType, normalizeLayout } from "./constants";
6
+ import { useAdsContext } from "./context";
7
+ import { layoutStylesFor, resolveAdsTheme } from "./default-theme";
8
+ import { useAdImpression, useAdRotation } from "./hooks";
9
+ import { lineupIdFromSource } from "./lineup-id";
10
+ import { renderLayoutByKey } from "./layouts";
11
+ import { openAdClickUrl } from "./open-click";
12
+ import { AdRotationShell } from "./rotation/shell";
13
+ import type { AdLayoutRenderContext, AdProps, AdRenderContext } from "./theme";
14
+
15
+ export type AdSlotViewProps = {
16
+ feed: AdsFeedResponse;
17
+ placement: string;
18
+ style?: AdProps["style"];
19
+ contentStyle?: AdProps["contentStyle"];
20
+ theme?: AdProps["theme"];
21
+ styles?: AdProps["styles"];
22
+ showIndicators?: boolean;
23
+ trackImpressions?: boolean;
24
+ onPress?: AdProps["onPress"];
25
+ renderAd?: AdProps["renderAd"];
26
+ renderLayout?: AdProps["renderLayout"];
27
+ };
28
+
29
+ export function AdSlotView({
30
+ feed,
31
+ placement,
32
+ style,
33
+ contentStyle,
34
+ theme: adTheme,
35
+ styles: adStyles,
36
+ showIndicators,
37
+ trackImpressions = true,
38
+ onPress,
39
+ renderAd,
40
+ renderLayout,
41
+ }: AdSlotViewProps) {
42
+ const { providerTheme, onInAppNavigate } = useAdsContext();
43
+ const ads = feed.ads ?? [];
44
+ const rotation = feed.rotation;
45
+ const adType = normalizeAdType(feed.type ?? ads[0]?.type);
46
+ const layout = normalizeLayout(adType, feed.layout);
47
+ const { index, setIndex, current, count } = useAdRotation({ ads, rotation, enabled: true });
48
+
49
+ const theme = useMemo(
50
+ () => resolveAdsTheme(providerTheme, adTheme, adStyles),
51
+ [providerTheme, adTheme, adStyles],
52
+ );
53
+ const creativeStyles = useMemo(() => layoutStylesFor(theme, adType, layout), [theme, adType, layout]);
54
+
55
+ useAdImpression(
56
+ trackImpressions ? current : null,
57
+ placement,
58
+ lineupIdFromSource(feed.source),
59
+ );
60
+
61
+ const handlePress = useCallback(async (ad: AdFeedItem) => {
62
+ onPress?.(ad);
63
+ const clickUrl = ad.click_url?.trim();
64
+ if (clickUrl) {
65
+ await openAdClickUrl(clickUrl, { onInAppNavigate });
66
+ }
67
+ }, [onInAppNavigate, onPress]);
68
+
69
+ const buildContext = useCallback((ad: AdFeedItem, adIndex: number): AdRenderContext => ({
70
+ ad,
71
+ adType,
72
+ layout,
73
+ placement,
74
+ index: adIndex,
75
+ count,
76
+ onPress: () => void handlePress(ad),
77
+ theme,
78
+ }), [adType, count, handlePress, layout, placement, theme]);
79
+
80
+ const renderOne = useCallback((ad: AdFeedItem, adIndex: number) => {
81
+ const ctx = buildContext(ad, adIndex);
82
+ if (renderAd) {
83
+ return renderAd(ctx);
84
+ }
85
+ const layoutCtx: AdLayoutRenderContext = { ...ctx, styles: creativeStyles };
86
+ if (renderLayout) {
87
+ return renderLayout(layoutCtx);
88
+ }
89
+ return renderLayoutByKey({ ctx: layoutCtx, styles: creativeStyles, contentStyle });
90
+ }, [buildContext, creativeStyles, contentStyle, renderAd, renderLayout]);
91
+
92
+ if (count === 0) {
93
+ return (
94
+ <View style={[theme.states.loading, style]}>
95
+ <Text style={theme.states.empty}>(无广告素材)</Text>
96
+ </View>
97
+ );
98
+ }
99
+
100
+ const indicators = showIndicators ?? rotation?.show_indicators ?? false;
101
+
102
+ return (
103
+ <AdRotationShell
104
+ ads={ads}
105
+ rotation={rotation}
106
+ index={index}
107
+ onIndexChange={setIndex}
108
+ styles={theme.rotation}
109
+ showIndicators={indicators}
110
+ rootStyle={style}
111
+ renderSlide={(ad, adIndex) => renderOne(ad, adIndex)}
112
+ />
113
+ );
114
+ }
115
+
116
+ export type AdPreviewProps = Omit<AdSlotViewProps, "placement"> & {
117
+ placement?: string;
118
+ };
119
+
120
+ /** Render a feed payload without fetching (labs, Storybook, admin preview). */
121
+ export function AdPreview({ feed, placement, trackImpressions = false, ...rest }: AdPreviewProps) {
122
+ const slotPlacement = placement ?? feed.placement ?? "preview";
123
+ return (
124
+ <AdSlotView
125
+ feed={feed}
126
+ placement={slotPlacement}
127
+ trackImpressions={trackImpressions}
128
+ {...rest}
129
+ />
130
+ );
131
+ }
package/src/ads/ad.tsx ADDED
@@ -0,0 +1,91 @@
1
+ import { useEffect } from "react";
2
+ import { ActivityIndicator, Text, View } from "react-native";
3
+
4
+ import type { AdProps } from "./theme";
5
+ import { AdSlotView } from "./ad-slot";
6
+ import { useAdsContext } from "./context";
7
+ import { resolveAdsTheme } from "./default-theme";
8
+ import { useAdsFeed } from "./hooks";
9
+
10
+ export function Ad({
11
+ placement = "home_banner",
12
+ style,
13
+ contentStyle,
14
+ theme: adTheme,
15
+ styles: adStyles,
16
+ onError,
17
+ onPress,
18
+ showEmpty = false,
19
+ showIndicators,
20
+ renderAd,
21
+ renderLayout,
22
+ }: AdProps) {
23
+ const { providerTheme } = useAdsContext();
24
+ const { feed, loading, error } = useAdsFeed({ placement });
25
+ const theme = resolveAdsTheme(providerTheme, adTheme, adStyles);
26
+
27
+ useEffect(() => {
28
+ if (error) {
29
+ onError?.(error);
30
+ }
31
+ }, [error, onError]);
32
+
33
+ if (loading) {
34
+ return (
35
+ <View style={[theme.states.loading, style]}>
36
+ <ActivityIndicator />
37
+ </View>
38
+ );
39
+ }
40
+
41
+ if (error) {
42
+ return (
43
+ <View style={[theme.states.loading, style]}>
44
+ <Text style={theme.states.error}>广告加载失败: {error.message}</Text>
45
+ </View>
46
+ );
47
+ }
48
+
49
+ if (!feed?.ads?.length) {
50
+ if (!showEmpty) {
51
+ return null;
52
+ }
53
+ return (
54
+ <View style={[theme.states.loading, style]}>
55
+ <Text style={theme.states.empty}>(当前无 {placement} 广告)</Text>
56
+ </View>
57
+ );
58
+ }
59
+
60
+ return (
61
+ <AdSlotView
62
+ feed={feed}
63
+ placement={placement}
64
+ style={style}
65
+ contentStyle={contentStyle}
66
+ theme={adTheme}
67
+ styles={adStyles}
68
+ showIndicators={showIndicators}
69
+ trackImpressions
70
+ onPress={onPress}
71
+ renderAd={renderAd}
72
+ renderLayout={renderLayout}
73
+ />
74
+ );
75
+ }
76
+
77
+ export function AdBanner(props: Omit<AdProps, "placement">) {
78
+ return <Ad placement="home_banner" {...props} />;
79
+ }
80
+
81
+ export function AdSidebar(props: Omit<AdProps, "placement">) {
82
+ return <Ad placement="sidebar" {...props} />;
83
+ }
84
+
85
+ export function AdFooter(props: Omit<AdProps, "placement">) {
86
+ return <Ad placement="footer" {...props} />;
87
+ }
88
+
89
+ export function AdInline(props: Omit<AdProps, "placement">) {
90
+ return <Ad placement="inline" {...props} />;
91
+ }
@@ -0,0 +1,9 @@
1
+ import Constants from "expo-constants";
2
+
3
+ export function appBundleId(): string {
4
+ const ios = Constants.expoConfig?.ios?.bundleIdentifier?.trim();
5
+ if (ios) {
6
+ return ios;
7
+ }
8
+ return Constants.expoConfig?.android?.package?.trim() ?? "";
9
+ }
@@ -0,0 +1,92 @@
1
+ export type AdType = "text" | "image";
2
+
3
+ export type TextLayout =
4
+ | "text_inline"
5
+ | "text_callout"
6
+ | "text_footer"
7
+ | "text_sidebar"
8
+ | "text_minimal";
9
+
10
+ export type ImageLayout =
11
+ | "image_hero"
12
+ | "image_card"
13
+ | "image_strip"
14
+ | "image_thumbnail"
15
+ | "image_inline";
16
+
17
+ export type AdLayout = TextLayout | ImageLayout;
18
+
19
+ export type AdRotationMode = "none" | "fade" | "slide" | "stack";
20
+
21
+ export const AD_TYPES: readonly AdType[] = ["text", "image"] as const;
22
+
23
+ export const TEXT_LAYOUTS: readonly TextLayout[] = [
24
+ "text_inline",
25
+ "text_callout",
26
+ "text_footer",
27
+ "text_sidebar",
28
+ "text_minimal",
29
+ ] as const;
30
+
31
+ export const IMAGE_LAYOUTS: readonly ImageLayout[] = [
32
+ "image_hero",
33
+ "image_card",
34
+ "image_strip",
35
+ "image_thumbnail",
36
+ "image_inline",
37
+ ] as const;
38
+
39
+ export const AD_ROTATION_MODES: readonly AdRotationMode[] = [
40
+ "none", "fade", "slide", "stack",
41
+ ] as const;
42
+
43
+ export const DEFAULT_TEXT_LAYOUT: TextLayout = "text_inline";
44
+ export const DEFAULT_IMAGE_LAYOUT: ImageLayout = "image_hero";
45
+ export const DEFAULT_ROTATION_MODE: AdRotationMode = "fade";
46
+ export const IMPRESSION_MIN_DWELL_MS = 300;
47
+ export const ROTATION_ANIM_MS = 300;
48
+
49
+ export function layoutsForType(type: AdType): readonly AdLayout[] {
50
+ return type === "text" ? TEXT_LAYOUTS : IMAGE_LAYOUTS;
51
+ }
52
+
53
+ export function defaultLayoutForType(type: AdType): AdLayout {
54
+ return type === "text" ? DEFAULT_TEXT_LAYOUT : DEFAULT_IMAGE_LAYOUT;
55
+ }
56
+
57
+ export function normalizeAdType(raw?: string): AdType {
58
+ const key = (raw ?? "").trim();
59
+ return key === "image" ? "image" : "text";
60
+ }
61
+
62
+ export function normalizeLayout(type: AdType, raw?: string): AdLayout {
63
+ const key = (raw ?? "").trim();
64
+ const allowed = layoutsForType(type);
65
+ if (allowed.includes(key as AdLayout)) {
66
+ return key as AdLayout;
67
+ }
68
+ return defaultLayoutForType(type);
69
+ }
70
+
71
+ export function normalizeRotationMode(raw?: string, enabled?: boolean): AdRotationMode {
72
+ if (!enabled) {
73
+ return "none";
74
+ }
75
+ const key = (raw ?? "").trim() as AdRotationMode;
76
+ return AD_ROTATION_MODES.includes(key) ? key : DEFAULT_ROTATION_MODE;
77
+ }
78
+
79
+ export function effectiveRotationMode(rotation?: { enabled?: boolean; mode?: string }, adCount = 0): AdRotationMode {
80
+ if (adCount <= 1 || !rotation?.enabled) {
81
+ return "none";
82
+ }
83
+ return normalizeRotationMode(rotation.mode, true);
84
+ }
85
+
86
+ export function isTextLayout(layout: AdLayout): layout is TextLayout {
87
+ return (TEXT_LAYOUTS as readonly string[]).includes(layout);
88
+ }
89
+
90
+ export function isImageLayout(layout: AdLayout): layout is ImageLayout {
91
+ return (IMAGE_LAYOUTS as readonly string[]).includes(layout);
92
+ }
@@ -0,0 +1,76 @@
1
+ import { createContext, useContext, useMemo, type ReactNode } from "react";
2
+
3
+ import { resolveGatewayUrlFromEnv } from "../gateway-url";
4
+ import type { PublicAdsRequestContext } from "../public-ads";
5
+ import { resolvePublishableKeyFromEnv } from "../publishable-key";
6
+ import { adsSurfaceKey } from "../platform";
7
+ import { appBundleId } from "./bundle-id";
8
+ import type { InAppNavigate } from "./open-click";
9
+ import type { AdsTheme } from "./theme";
10
+
11
+ export type AdsProviderProps = {
12
+ /** When set with gatewayUrl, feed/events use Public Ads API (no Integration Key). */
13
+ publishableKey?: string;
14
+ /**
15
+ * Platform API base for Public Ads (`/v1/public/ads`).
16
+ * Defaults to EXPO_PUBLIC_GATEWAY_URL.
17
+ * Do not pass the OAuth issuer (Hosted UI login host).
18
+ */
19
+ gatewayUrl?: string;
20
+ /** @deprecated Use gatewayUrl */
21
+ publicAdsGatewayUrl?: string;
22
+ /** Optional; when logged in, impressions may attach user_id on public events. */
23
+ resolveAccessToken?: () => Promise<string | null>;
24
+ /** Routes `app:/path` ad clicks inside the app. Path includes an optional query. */
25
+ onInAppNavigate?: InAppNavigate;
26
+ theme?: AdsTheme;
27
+ children: ReactNode;
28
+ };
29
+
30
+ type AdsContextValue = {
31
+ resolveAccessToken?: () => Promise<string | null>;
32
+ onInAppNavigate?: InAppNavigate;
33
+ providerTheme?: AdsTheme;
34
+ publicAds?: PublicAdsRequestContext;
35
+ };
36
+
37
+ const AdsContext = createContext<AdsContextValue | null>(null);
38
+
39
+ export function AdsProvider({
40
+ publishableKey,
41
+ gatewayUrl,
42
+ publicAdsGatewayUrl,
43
+ resolveAccessToken,
44
+ onInAppNavigate,
45
+ theme,
46
+ children,
47
+ }: AdsProviderProps) {
48
+ const value = useMemo((): AdsContextValue => {
49
+ const key = publishableKey?.trim() || resolvePublishableKeyFromEnv();
50
+ const base =
51
+ gatewayUrl?.trim() ||
52
+ publicAdsGatewayUrl?.trim() ||
53
+ resolveGatewayUrlFromEnv() ||
54
+ "";
55
+ let publicAds: PublicAdsRequestContext | undefined;
56
+ if (key && base) {
57
+ publicAds = {
58
+ gatewayUrl: base,
59
+ publishableKey: key,
60
+ surfaceKey: adsSurfaceKey(),
61
+ bundleId: appBundleId(),
62
+ };
63
+ }
64
+ return { resolveAccessToken, onInAppNavigate, providerTheme: theme, publicAds };
65
+ }, [publishableKey, gatewayUrl, publicAdsGatewayUrl, resolveAccessToken, onInAppNavigate, theme]);
66
+
67
+ return <AdsContext.Provider value={value}>{children}</AdsContext.Provider>;
68
+ }
69
+
70
+ export function useAdsContext(): AdsContextValue {
71
+ const ctx = useContext(AdsContext);
72
+ if (!ctx) {
73
+ throw new Error("useAdsContext must be used within AdsProvider");
74
+ }
75
+ return ctx;
76
+ }
@@ -0,0 +1,105 @@
1
+ import { Image, Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
2
+
3
+ import type { AdFeedItem } from "../types";
4
+ import type { AdCreativeStyles } from "./theme";
5
+
6
+ export type AdCreativeViewProps = {
7
+ ad: AdFeedItem;
8
+ onPress: () => void;
9
+ styles: AdCreativeStyles;
10
+ contentStyle?: StyleProp<ViewStyle>;
11
+ showImage?: boolean;
12
+ variant?: "stacked" | "row" | "compact" | "inline" | "text";
13
+ };
14
+
15
+ function pressableStyle(styles: AdCreativeStyles, pressed: boolean, extra?: StyleProp<ViewStyle>) {
16
+ return [styles.root, pressed && styles.pressed, extra];
17
+ }
18
+
19
+ export function AdCreativeView({
20
+ ad, onPress, styles, contentStyle, showImage = true, variant = "stacked",
21
+ }: AdCreativeViewProps) {
22
+ const hasImage = showImage && ad.type === "image" && !!ad.image_url;
23
+ const label = ad.title || ad.body_text || "Platform ad";
24
+
25
+ if (variant === "row" && hasImage) {
26
+ return (
27
+ <Pressable
28
+ onPress={onPress}
29
+ style={({ pressed }) => pressableStyle(styles, pressed, contentStyle)}
30
+ accessibilityRole="button"
31
+ accessibilityLabel={label}
32
+ >
33
+ <Image source={{ uri: ad.image_url }} style={styles.image} resizeMode="cover" />
34
+ <View style={styles.content}>
35
+ {ad.title ? <Text style={styles.title}>{ad.title}</Text> : null}
36
+ {ad.body_text ? <Text style={styles.body}>{ad.body_text}</Text> : null}
37
+ </View>
38
+ </Pressable>
39
+ );
40
+ }
41
+
42
+ if (variant === "compact" && hasImage) {
43
+ return (
44
+ <Pressable
45
+ onPress={onPress}
46
+ style={({ pressed }) => pressableStyle(styles, pressed, [contentStyle, { flexDirection: "row", alignItems: "center", gap: 8 }])}
47
+ accessibilityRole="button"
48
+ accessibilityLabel={label}
49
+ >
50
+ <Image source={{ uri: ad.image_url }} style={styles.image} resizeMode="cover" />
51
+ <View style={styles.content}>
52
+ {ad.title ? <Text style={styles.title}>{ad.title}</Text> : null}
53
+ {ad.body_text ? <Text style={styles.body} numberOfLines={1}>{ad.body_text}</Text> : null}
54
+ </View>
55
+ </Pressable>
56
+ );
57
+ }
58
+
59
+ if (variant === "text") {
60
+ return (
61
+ <Pressable
62
+ onPress={onPress}
63
+ style={({ pressed }) => pressableStyle(styles, pressed, contentStyle)}
64
+ accessibilityRole="button"
65
+ accessibilityLabel={label}
66
+ >
67
+ {ad.title ? <Text style={styles.title}>{ad.title}</Text> : null}
68
+ {ad.body_text ? <Text style={styles.body}>{ad.body_text}</Text> : null}
69
+ <Text style={styles.link}>Learn more</Text>
70
+ </Pressable>
71
+ );
72
+ }
73
+
74
+ if (hasImage) {
75
+ return (
76
+ <Pressable
77
+ onPress={onPress}
78
+ style={({ pressed }) => pressableStyle(styles, pressed, contentStyle)}
79
+ accessibilityRole="button"
80
+ accessibilityLabel={label}
81
+ >
82
+ <Image source={{ uri: ad.image_url }} style={styles.image} resizeMode="cover" />
83
+ {(ad.title || ad.body_text) ? (
84
+ <View style={styles.caption}>
85
+ {ad.title ? <Text style={styles.title}>{ad.title}</Text> : null}
86
+ {ad.body_text ? <Text style={styles.body}>{ad.body_text}</Text> : null}
87
+ </View>
88
+ ) : null}
89
+ </Pressable>
90
+ );
91
+ }
92
+
93
+ return (
94
+ <Pressable
95
+ onPress={onPress}
96
+ style={({ pressed }) => pressableStyle(styles, pressed, [contentStyle, variant === "inline" && { gap: 4 }])}
97
+ accessibilityRole="button"
98
+ accessibilityLabel={label}
99
+ >
100
+ {ad.title ? <Text style={styles.title}>{ad.title}</Text> : null}
101
+ {ad.body_text ? <Text style={styles.body}>{ad.body_text}</Text> : null}
102
+ {variant === "inline" && ad.click_url ? <Text style={styles.link}>Learn more</Text> : null}
103
+ </Pressable>
104
+ );
105
+ }