@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.
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/package.json +49 -0
- package/src/ads/ad-slot.tsx +131 -0
- package/src/ads/ad.tsx +91 -0
- package/src/ads/bundle-id.ts +9 -0
- package/src/ads/constants.ts +92 -0
- package/src/ads/context.tsx +76 -0
- package/src/ads/creative.tsx +105 -0
- package/src/ads/default-theme.ts +284 -0
- package/src/ads/hooks.ts +156 -0
- package/src/ads/index.ts +32 -0
- package/src/ads/layouts/image.tsx +101 -0
- package/src/ads/layouts/index.tsx +28 -0
- package/src/ads/layouts/shared.tsx +80 -0
- package/src/ads/layouts/text.tsx +77 -0
- package/src/ads/lineup-id.ts +6 -0
- package/src/ads/open-click.ts +61 -0
- package/src/ads/rotation/indicators-context.tsx +31 -0
- package/src/ads/rotation/indicators.tsx +22 -0
- package/src/ads/rotation/shell.tsx +98 -0
- package/src/ads/session-id.ts +18 -0
- package/src/ads/theme.ts +105 -0
- package/src/ads-ui.tsx +40 -0
- package/src/ads.ts +16 -0
- package/src/app-update/context.tsx +380 -0
- package/src/auth-errors.ts +50 -0
- package/src/auth-session.ts +129 -0
- package/src/auth-storage.ts +62 -0
- package/src/auth.tsx +249 -0
- package/src/dev.ts +16 -0
- package/src/drive/types.ts +62 -0
- package/src/drive.ts +355 -0
- package/src/errors.ts +11 -0
- package/src/gateway-url.ts +7 -0
- package/src/hosted-ui-link.ts +265 -0
- package/src/index.ts +161 -0
- package/src/platform-api-base.ts +72 -0
- package/src/platform.ts +16 -0
- package/src/public-ads.ts +105 -0
- package/src/public-app-config.ts +106 -0
- package/src/public-auth.ts +252 -0
- package/src/publishable-key.ts +19 -0
- package/src/push.ts +89 -0
- package/src/types.ts +123 -0
- 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,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
|
+
}
|