@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
@@ -0,0 +1,284 @@
1
+ import { StyleSheet, type TextStyle } from "react-native";
2
+
3
+ import type { AdLayout, AdType, ImageLayout, TextLayout } from "./constants";
4
+ import { IMAGE_LAYOUTS, TEXT_LAYOUTS } from "./constants";
5
+ import type { AdCreativeStyles, AdLayoutStyles, AdsStateStyles, AdsTheme, AdRotationStyles, ResolvedAdsTheme } from "./theme";
6
+
7
+ export const defaultCreativeStyles = StyleSheet.create({
8
+ root: {
9
+ position: "relative",
10
+ overflow: "hidden",
11
+ borderRadius: 12,
12
+ borderWidth: StyleSheet.hairlineWidth,
13
+ borderColor: "#ddd",
14
+ backgroundColor: "#fff",
15
+ },
16
+ pressed: { opacity: 0.85 },
17
+ image: { width: "100%", height: 160 },
18
+ caption: { paddingHorizontal: 16, paddingVertical: 10, gap: 2 },
19
+ title: { fontSize: 14, fontWeight: "600", color: "#111" },
20
+ body: { fontSize: 13, color: "#666", lineHeight: 18 },
21
+ link: { fontSize: 13, color: "#2563eb", lineHeight: 18 },
22
+ badge: {
23
+ alignSelf: "flex-start",
24
+ fontSize: 10,
25
+ fontWeight: "700",
26
+ letterSpacing: 0.6,
27
+ textTransform: "uppercase",
28
+ color: "#2563eb",
29
+ marginBottom: 4,
30
+ },
31
+ overlay: {
32
+ position: "absolute",
33
+ left: 0,
34
+ right: 0,
35
+ bottom: 0,
36
+ paddingHorizontal: 14,
37
+ paddingVertical: 12,
38
+ gap: 2,
39
+ backgroundColor: "rgba(0,0,0,0.45)",
40
+ },
41
+ media: { overflow: "hidden" },
42
+ content: { gap: 4 },
43
+ });
44
+
45
+ export const defaultRotationStyles = StyleSheet.create({
46
+ root: {},
47
+ slide: {},
48
+ indicators: {
49
+ position: "absolute",
50
+ right: 10,
51
+ bottom: 10,
52
+ flexDirection: "row",
53
+ alignItems: "center",
54
+ gap: 5,
55
+ zIndex: 20,
56
+ paddingHorizontal: 6,
57
+ paddingVertical: 4,
58
+ borderRadius: 999,
59
+ backgroundColor: "rgba(255,255,255,0.72)",
60
+ },
61
+ dot: { width: 6, height: 6, borderRadius: 3, backgroundColor: "rgba(0,0,0,0.28)" },
62
+ dotActive: { backgroundColor: "rgba(0,0,0,0.72)" },
63
+ });
64
+
65
+ export const defaultStateStyles = StyleSheet.create({
66
+ loading: {
67
+ overflow: "hidden",
68
+ borderRadius: 12,
69
+ borderWidth: StyleSheet.hairlineWidth,
70
+ borderColor: "#ddd",
71
+ backgroundColor: "#fff",
72
+ paddingHorizontal: 16,
73
+ paddingVertical: 12,
74
+ alignItems: "center",
75
+ },
76
+ empty: { fontSize: 13, color: "#888", lineHeight: 18 },
77
+ error: { fontSize: 13, color: "#b00020", lineHeight: 18 },
78
+ });
79
+
80
+ const textLayoutOverrides: Record<TextLayout, AdCreativeStyles> = {
81
+ text_inline: {
82
+ root: { borderRadius: 8, borderWidth: 0, backgroundColor: "#f4f4f5", paddingHorizontal: 14, paddingVertical: 12 },
83
+ content: { gap: 4 },
84
+ title: { fontSize: 14, fontWeight: "600" },
85
+ body: { fontSize: 14, lineHeight: 20, color: "#64748b" },
86
+ link: { marginTop: 8, fontWeight: "500" },
87
+ },
88
+ text_callout: {
89
+ root: {
90
+ paddingHorizontal: 14,
91
+ paddingVertical: 12,
92
+ gap: 4,
93
+ borderLeftWidth: 4,
94
+ borderLeftColor: "#2563eb",
95
+ backgroundColor: "#eff6ff",
96
+ borderWidth: 0,
97
+ borderRadius: 8,
98
+ },
99
+ title: { fontSize: 14, fontWeight: "700" },
100
+ body: { fontSize: 14, lineHeight: 20, color: "#64748b" },
101
+ link: { marginTop: 8, fontWeight: "600" },
102
+ },
103
+ text_footer: {
104
+ root: {
105
+ paddingHorizontal: 16,
106
+ paddingVertical: 10,
107
+ borderRadius: 8,
108
+ backgroundColor: "#f4f4f5",
109
+ borderColor: "#e4e4e7",
110
+ alignItems: "center",
111
+ },
112
+ title: { fontSize: 12, fontWeight: "500", color: "#64748b", textAlign: "center", lineHeight: 17 },
113
+ },
114
+ text_sidebar: {
115
+ root: {
116
+ maxWidth: 320,
117
+ paddingHorizontal: 12,
118
+ paddingVertical: 12,
119
+ gap: 4,
120
+ borderTopWidth: 3,
121
+ borderTopColor: "#2563eb",
122
+ borderRadius: 8,
123
+ },
124
+ content: { gap: 4 },
125
+ title: { fontSize: 14, fontWeight: "700" },
126
+ body: { fontSize: 12, lineHeight: 17, color: "#64748b" },
127
+ },
128
+ text_minimal: {
129
+ root: { paddingHorizontal: 0, paddingVertical: 0, borderWidth: 0, backgroundColor: "transparent" },
130
+ link: { fontSize: 14, fontWeight: "500", color: "#2563eb" },
131
+ },
132
+ };
133
+
134
+ const heroTitle: TextStyle = { fontSize: 14, fontWeight: "700", color: "#fff" };
135
+ const heroBody: TextStyle = { fontSize: 12, color: "rgba(255,255,255,0.9)", lineHeight: 16 };
136
+
137
+ const imageLayoutOverrides: Record<ImageLayout, AdCreativeStyles> = {
138
+ image_hero: {
139
+ root: { paddingVertical: 0, paddingHorizontal: 0, borderWidth: 0, borderRadius: 8 },
140
+ media: { width: "100%" },
141
+ image: { width: "100%", height: 144 },
142
+ overlay: { paddingHorizontal: 12, paddingVertical: 10 },
143
+ title: heroTitle,
144
+ body: heroBody,
145
+ },
146
+ image_card: {
147
+ root: {
148
+ paddingVertical: 0,
149
+ paddingHorizontal: 0,
150
+ borderRadius: 8,
151
+ borderWidth: 0,
152
+ backgroundColor: "#fff",
153
+ shadowColor: "#000",
154
+ shadowOffset: { width: 0, height: 2 },
155
+ shadowOpacity: 0.08,
156
+ shadowRadius: 8,
157
+ elevation: 3,
158
+ },
159
+ media: { overflow: "hidden" },
160
+ image: { width: "100%", height: 128 },
161
+ caption: { paddingHorizontal: 14, paddingVertical: 12, gap: 4 },
162
+ title: { fontSize: 14, fontWeight: "700" },
163
+ body: { fontSize: 14, lineHeight: 20, color: "#64748b" },
164
+ link: { paddingTop: 4, fontWeight: "600" },
165
+ },
166
+ image_strip: {
167
+ root: { padding: 12, borderRadius: 8 },
168
+ media: { borderRadius: 8, overflow: "hidden" },
169
+ image: { width: 56, height: 56, borderRadius: 8 },
170
+ content: { flex: 1, gap: 2, minWidth: 0 },
171
+ title: { fontSize: 14, fontWeight: "600" },
172
+ body: { fontSize: 12, lineHeight: 16, color: "#64748b" },
173
+ },
174
+ image_thumbnail: {
175
+ root: {
176
+ maxWidth: 384,
177
+ padding: 12,
178
+ borderRadius: 8,
179
+ borderWidth: 0,
180
+ backgroundColor: "#f4f4f5",
181
+ },
182
+ media: { borderRadius: 8, overflow: "hidden" },
183
+ image: { width: 64, height: 64, borderRadius: 8 },
184
+ content: { flex: 1, gap: 4 },
185
+ title: { fontSize: 14, fontWeight: "700" },
186
+ body: { fontSize: 12, lineHeight: 17, color: "#64748b" },
187
+ link: { fontSize: 12, fontWeight: "600", marginTop: 4 },
188
+ },
189
+ image_inline: {
190
+ root: { paddingVertical: 0, paddingHorizontal: 0, borderWidth: 0, backgroundColor: "transparent", gap: 8 },
191
+ media: { borderRadius: 8, overflow: "hidden" },
192
+ image: { width: "100%", height: 112, borderRadius: 8 },
193
+ caption: { paddingHorizontal: 0, paddingVertical: 0, gap: 4 },
194
+ title: { fontSize: 14, fontWeight: "600" },
195
+ body: { fontSize: 12, color: "#64748b" },
196
+ },
197
+ };
198
+
199
+ export const defaultAdsTheme: AdsTheme = {
200
+ creative: defaultCreativeStyles,
201
+ rotation: defaultRotationStyles,
202
+ layouts: { text: textLayoutOverrides, image: imageLayoutOverrides },
203
+ states: defaultStateStyles,
204
+ };
205
+
206
+ function mergeCreative(base: AdCreativeStyles, ...layers: (AdCreativeStyles | undefined)[]): AdCreativeStyles {
207
+ const nestedKeys: (keyof AdCreativeStyles)[] = ["overlay", "caption", "media", "content"];
208
+
209
+ return layers.reduce<AdCreativeStyles>((acc, layer) => {
210
+ if (!layer) {
211
+ return acc;
212
+ }
213
+ const next = { ...acc, ...layer };
214
+ for (const key of nestedKeys) {
215
+ if (layer[key]) {
216
+ next[key] = { ...(acc[key] as object), ...(layer[key] as object) };
217
+ }
218
+ }
219
+ return next;
220
+ }, { ...base });
221
+ }
222
+
223
+ function buildTextLayouts(creative: AdCreativeStyles, patch?: AdLayoutStyles["text"]): Record<TextLayout, AdCreativeStyles> {
224
+ const out = {} as Record<TextLayout, AdCreativeStyles>;
225
+ for (const key of TEXT_LAYOUTS) {
226
+ out[key] = mergeCreative(creative, textLayoutOverrides[key], patch?.[key]);
227
+ }
228
+ return out;
229
+ }
230
+
231
+ function buildImageLayouts(creative: AdCreativeStyles, patch?: AdLayoutStyles["image"]): Record<ImageLayout, AdCreativeStyles> {
232
+ const out = {} as Record<ImageLayout, AdCreativeStyles>;
233
+ for (const key of IMAGE_LAYOUTS) {
234
+ out[key] = mergeCreative(creative, imageLayoutOverrides[key], patch?.[key]);
235
+ }
236
+ return out;
237
+ }
238
+
239
+ export function resolveAdsTheme(
240
+ providerTheme?: AdsTheme,
241
+ adTheme?: AdsTheme,
242
+ adStyles?: AdLayoutStyles,
243
+ ): ResolvedAdsTheme {
244
+ const creative = mergeCreative(
245
+ defaultCreativeStyles,
246
+ defaultAdsTheme.creative,
247
+ providerTheme?.creative,
248
+ adTheme?.creative,
249
+ );
250
+ const rotation: AdRotationStyles = {
251
+ ...defaultRotationStyles,
252
+ ...defaultAdsTheme.rotation,
253
+ ...providerTheme?.rotation,
254
+ ...adTheme?.rotation,
255
+ };
256
+ const layouts = {
257
+ text: buildTextLayouts(creative, {
258
+ ...defaultAdsTheme.layouts?.text,
259
+ ...providerTheme?.layouts?.text,
260
+ ...adTheme?.layouts?.text,
261
+ ...adStyles?.text,
262
+ }),
263
+ image: buildImageLayouts(creative, {
264
+ ...defaultAdsTheme.layouts?.image,
265
+ ...providerTheme?.layouts?.image,
266
+ ...adTheme?.layouts?.image,
267
+ ...adStyles?.image,
268
+ }),
269
+ };
270
+ const states: AdsStateStyles = {
271
+ ...defaultStateStyles,
272
+ ...defaultAdsTheme.states,
273
+ ...providerTheme?.states,
274
+ ...adTheme?.states,
275
+ };
276
+ return { creative, rotation, layouts, states };
277
+ }
278
+
279
+ export function layoutStylesFor(theme: ResolvedAdsTheme, adType: AdType, layout: AdLayout): AdCreativeStyles {
280
+ if (adType === "image") {
281
+ return theme.layouts.image[layout as ImageLayout] ?? theme.creative;
282
+ }
283
+ return theme.layouts.text[layout as TextLayout] ?? theme.creative;
284
+ }
@@ -0,0 +1,156 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { AppState } from "react-native";
3
+
4
+ import { getPublicAdsFeed, recordPublicAdEvents } from "../public-ads";
5
+ import type { AdFeedItem, AdFeedRotation } from "../types";
6
+ import { IMPRESSION_MIN_DWELL_MS } from "./constants";
7
+ import { useAdsContext } from "./context";
8
+ import { getOrCreateAdsSessionId } from "./session-id";
9
+ import type { UseAdsFeedOptions, UseAdsFeedResult } from "./theme";
10
+
11
+ const impressionRecorded = new Set<string>();
12
+
13
+ export function useAdsFeed(options: UseAdsFeedOptions = {}): UseAdsFeedResult {
14
+ const { resolveAccessToken, publicAds } = useAdsContext();
15
+ const placement = options.placement ?? "home_banner";
16
+ const enabled = options.enabled ?? true;
17
+
18
+ const [feed, setFeed] = useState<UseAdsFeedResult["feed"]>(null);
19
+ const [loading, setLoading] = useState(enabled);
20
+ const [error, setError] = useState<Error | null>(null);
21
+
22
+ const refresh = useCallback(async () => {
23
+ if (!enabled) {
24
+ setFeed(null);
25
+ setLoading(false);
26
+ return;
27
+ }
28
+ setLoading(true);
29
+ setError(null);
30
+ try {
31
+ if (!publicAds) {
32
+ setFeed(null);
33
+ setError(new Error("AdsProvider: publishableKey and gatewayUrl are required for Public Ads"));
34
+ return;
35
+ }
36
+ const next = await getPublicAdsFeed(publicAds, placement);
37
+ setFeed(next);
38
+ } catch (err) {
39
+ setError(err instanceof Error ? err : new Error(String(err)));
40
+ setFeed(null);
41
+ } finally {
42
+ setLoading(false);
43
+ }
44
+ }, [publicAds, placement, enabled]);
45
+
46
+ useEffect(() => {
47
+ void refresh();
48
+ }, [refresh]);
49
+
50
+ const ads = feed?.ads ?? [];
51
+ const ad = ads[0] ?? null;
52
+ return {
53
+ feed, ad, ads, layout: feed?.layout, rotation: feed?.rotation,
54
+ loading, error, refresh,
55
+ };
56
+ }
57
+
58
+ type UseAdRotationOptions = {
59
+ ads: AdFeedItem[];
60
+ rotation?: AdFeedRotation;
61
+ enabled?: boolean;
62
+ };
63
+
64
+ export type UseAdRotationResult = {
65
+ index: number;
66
+ setIndex: (index: number) => void;
67
+ current: AdFeedItem | null;
68
+ count: number;
69
+ };
70
+
71
+ export function useAdRotation({ ads, rotation, enabled = true }: UseAdRotationOptions): UseAdRotationResult {
72
+ const [index, setIndex] = useState(0);
73
+ const paused = useRef(false);
74
+
75
+ const autoplay = enabled && rotation?.enabled && rotation.autoplay !== false && ads.length > 1;
76
+ const intervalMs = Math.max(1000, (rotation?.interval_sec ?? 5) * 1000);
77
+
78
+ useEffect(() => {
79
+ setIndex(0);
80
+ }, [ads]);
81
+
82
+ useEffect(() => {
83
+ if (!autoplay || ads.length <= 1) {
84
+ return;
85
+ }
86
+ const timer = setInterval(() => {
87
+ if (paused.current) {
88
+ return;
89
+ }
90
+ setIndex((prev) => {
91
+ const next = prev + 1;
92
+ if (next >= ads.length) {
93
+ return rotation?.loop === false ? prev : 0;
94
+ }
95
+ return next;
96
+ });
97
+ }, intervalMs);
98
+ return () => clearInterval(timer);
99
+ }, [ads, autoplay, intervalMs, rotation?.loop]);
100
+
101
+ useEffect(() => {
102
+ const sub = AppState.addEventListener("change", (state) => {
103
+ paused.current = state !== "active";
104
+ });
105
+ return () => sub.remove();
106
+ }, []);
107
+
108
+ const current = ads[index] ?? null;
109
+ return { index, setIndex, current, count: ads.length };
110
+ }
111
+
112
+ export function useAdImpression(
113
+ ad: AdFeedItem | null,
114
+ placement: string,
115
+ lineupId: string,
116
+ ) {
117
+ const { resolveAccessToken, publicAds } = useAdsContext();
118
+ const sent = useRef<string>("");
119
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
120
+
121
+ useEffect(() => {
122
+ if (timer.current) {
123
+ clearTimeout(timer.current);
124
+ timer.current = null;
125
+ }
126
+ if (!ad?.id || !publicAds) {
127
+ return;
128
+ }
129
+ const key = `${ad.id}:${placement}`;
130
+ timer.current = setTimeout(() => {
131
+ if (sent.current === key || impressionRecorded.has(key)) {
132
+ return;
133
+ }
134
+ sent.current = key;
135
+ impressionRecorded.add(key);
136
+ void (async () => {
137
+ try {
138
+ const event = {
139
+ adId: ad.id, eventType: "impression" as const, placementKey: placement, lineupId,
140
+ };
141
+ const sessionId = await getOrCreateAdsSessionId();
142
+ const token = resolveAccessToken ? await resolveAccessToken() : null;
143
+ await recordPublicAdEvents(publicAds, sessionId, [event], token);
144
+ } catch (err) {
145
+ console.warn("Ad: impression tracking failed", err);
146
+ }
147
+ })();
148
+ }, IMPRESSION_MIN_DWELL_MS);
149
+ return () => {
150
+ if (timer.current) {
151
+ clearTimeout(timer.current);
152
+ timer.current = null;
153
+ }
154
+ };
155
+ }, [ad?.id, lineupId, placement, publicAds, resolveAccessToken]);
156
+ }
@@ -0,0 +1,32 @@
1
+ export { AdsProvider, useAdsContext, type AdsProviderProps } from "./context";
2
+ export type { InAppNavigate } from "./open-click";
3
+ export { useAdsFeed, useAdRotation, type UseAdRotationResult } from "./hooks";
4
+ export { Ad, AdBanner, AdSidebar, AdFooter, AdInline } from "./ad";
5
+ export { AdPreview, AdSlotView } from "./ad-slot";
6
+ export type { AdPreviewProps, AdSlotViewProps } from "./ad-slot";
7
+ export { defaultAdsTheme } from "./default-theme";
8
+ export {
9
+ normalizeAdType,
10
+ normalizeLayout,
11
+ normalizeRotationMode,
12
+ effectiveRotationMode,
13
+ layoutsForType,
14
+ defaultLayoutForType,
15
+ TEXT_LAYOUTS,
16
+ IMAGE_LAYOUTS,
17
+ AD_TYPES,
18
+ } from "./constants";
19
+ export type {
20
+ AdProps,
21
+ AdsTheme,
22
+ AdCreativeStyles,
23
+ AdLayoutStyles,
24
+ AdRotationStyles,
25
+ AdRenderContext,
26
+ AdLayoutRenderContext,
27
+ UseAdsFeedOptions,
28
+ UseAdsFeedResult,
29
+ TextLayoutStyles,
30
+ ImageLayoutStyles,
31
+ } from "./theme";
32
+ export type { AdType, AdLayout, TextLayout, ImageLayout, AdRotationMode } from "./constants";
@@ -0,0 +1,101 @@
1
+ import type { ReactElement } from "react";
2
+ import { StyleSheet, Text, View } from "react-native";
3
+
4
+ import type { ImageLayout } from "../constants";
5
+ import type { AdCreativeStyles, AdLayoutRenderContext } from "../theme";
6
+ import { AdBody, AdImage, AdLinkCta, AdTitle, LayoutShell } from "./shared";
7
+ import { useAdRotationIndicators } from "../rotation/indicators-context";
8
+
9
+ export type AdLayoutRendererProps = {
10
+ ctx: AdLayoutRenderContext;
11
+ styles: AdCreativeStyles;
12
+ contentStyle?: import("react-native").StyleProp<import("react-native").ViewStyle>;
13
+ };
14
+
15
+ const layoutRows = StyleSheet.create({
16
+ strip: { flexDirection: "row", alignItems: "center", gap: 12, width: "100%" },
17
+ thumbnail: { flexDirection: "row", alignItems: "flex-start", gap: 12, width: "100%" },
18
+ });
19
+
20
+ function ImageHeroLayout({ ctx, styles, contentStyle }: AdLayoutRendererProps) {
21
+ return (
22
+ <LayoutShell ad={ctx.ad} onPress={ctx.onPress} styles={styles} contentStyle={contentStyle}>
23
+ <AdImage uri={ctx.ad.image_url} styles={styles} />
24
+ <View style={styles.overlay}>
25
+ <AdTitle text={ctx.ad.title} styles={styles} />
26
+ <AdBody text={ctx.ad.body_text} styles={styles} numberOfLines={2} />
27
+ </View>
28
+ </LayoutShell>
29
+ );
30
+ }
31
+
32
+ function ImageCardLayout({ ctx, styles, contentStyle }: AdLayoutRendererProps) {
33
+ return (
34
+ <LayoutShell ad={ctx.ad} onPress={ctx.onPress} styles={styles} contentStyle={contentStyle}>
35
+ <AdImage uri={ctx.ad.image_url} styles={styles} />
36
+ <View style={styles.caption}>
37
+ <AdTitle text={ctx.ad.title} styles={styles} />
38
+ <AdBody text={ctx.ad.body_text} styles={styles} numberOfLines={2} />
39
+ <AdLinkCta styles={styles} />
40
+ </View>
41
+ </LayoutShell>
42
+ );
43
+ }
44
+
45
+ function ImageStripLayout({ ctx, styles, contentStyle }: AdLayoutRendererProps) {
46
+ const indicatorState = useAdRotationIndicators();
47
+
48
+ return (
49
+ <LayoutShell ad={ctx.ad} onPress={ctx.onPress} styles={styles} contentStyle={contentStyle}>
50
+ <View style={layoutRows.strip}>
51
+ <AdImage uri={ctx.ad.image_url} styles={styles} />
52
+ <View style={[styles.content, indicatorState ? stripIndicatorPad : null]}>
53
+ <AdTitle text={ctx.ad.title} styles={styles} numberOfLines={1} />
54
+ <AdBody text={ctx.ad.body_text} styles={styles} numberOfLines={1} />
55
+ </View>
56
+ </View>
57
+ </LayoutShell>
58
+ );
59
+ }
60
+
61
+ const stripIndicatorPad = { paddingRight: 28 };
62
+
63
+ function ImageThumbnailLayout({ ctx, styles, contentStyle }: AdLayoutRendererProps) {
64
+ return (
65
+ <LayoutShell ad={ctx.ad} onPress={ctx.onPress} styles={styles} contentStyle={contentStyle}>
66
+ <View style={layoutRows.thumbnail}>
67
+ <AdImage uri={ctx.ad.image_url} styles={styles} />
68
+ <View style={styles.content}>
69
+ <AdTitle text={ctx.ad.title} styles={styles} />
70
+ <AdBody text={ctx.ad.body_text} styles={styles} numberOfLines={2} />
71
+ <Text style={styles.link}>Open</Text>
72
+ </View>
73
+ </View>
74
+ </LayoutShell>
75
+ );
76
+ }
77
+
78
+ function ImageInlineLayout({ ctx, styles, contentStyle }: AdLayoutRendererProps) {
79
+ return (
80
+ <LayoutShell ad={ctx.ad} onPress={ctx.onPress} styles={styles} contentStyle={contentStyle}>
81
+ <AdImage uri={ctx.ad.image_url} styles={styles} />
82
+ <View style={styles.caption}>
83
+ <AdTitle text={ctx.ad.title} styles={styles} />
84
+ <AdBody text={ctx.ad.body_text} styles={styles} />
85
+ </View>
86
+ </LayoutShell>
87
+ );
88
+ }
89
+
90
+ const imageLayoutMap: Record<ImageLayout, (props: AdLayoutRendererProps) => ReactElement> = {
91
+ image_hero: ImageHeroLayout,
92
+ image_card: ImageCardLayout,
93
+ image_strip: ImageStripLayout,
94
+ image_thumbnail: ImageThumbnailLayout,
95
+ image_inline: ImageInlineLayout,
96
+ };
97
+
98
+ export function renderImageLayout(layout: ImageLayout, props: AdLayoutRendererProps): ReactElement {
99
+ const Layout = imageLayoutMap[layout] ?? ImageHeroLayout;
100
+ return <Layout {...props} />;
101
+ }
@@ -0,0 +1,28 @@
1
+ import type { ReactElement } from "react";
2
+
3
+ import { isImageLayout, isTextLayout } from "../constants";
4
+ import type { AdCreativeStyles, AdLayoutRenderContext } from "../theme";
5
+ import { renderImageLayout, type AdLayoutRendererProps } from "./image";
6
+ import { renderTextLayout } from "./text";
7
+
8
+ export type { AdLayoutRendererProps };
9
+
10
+ export function renderLayoutByKey(props: AdLayoutRendererProps): ReactElement {
11
+ const { ctx } = props;
12
+ const layout = ctx.layout;
13
+
14
+ if (ctx.adType === "image" && isImageLayout(layout)) {
15
+ return renderImageLayout(layout, props);
16
+ }
17
+ if (ctx.adType === "text" && isTextLayout(layout)) {
18
+ return renderTextLayout(layout, props);
19
+ }
20
+
21
+ if (ctx.adType === "image") {
22
+ return renderImageLayout("image_hero", props);
23
+ }
24
+ return renderTextLayout("text_inline", props);
25
+ }
26
+
27
+ export { renderTextLayout } from "./text";
28
+ export { renderImageLayout } from "./image";
@@ -0,0 +1,80 @@
1
+ import type { ReactNode } from "react";
2
+ import { Image, Pressable, Text, View, type StyleProp, type ViewStyle } from "react-native";
3
+
4
+ import type { AdFeedItem } from "../../types";
5
+ import { AdRotationIndicators } from "../rotation/indicators";
6
+ import { useAdRotationIndicators } from "../rotation/indicators-context";
7
+ import type { AdCreativeStyles } from "../theme";
8
+
9
+ export type LayoutShellProps = {
10
+ ad: AdFeedItem;
11
+ onPress: () => void;
12
+ styles: AdCreativeStyles;
13
+ contentStyle?: StyleProp<ViewStyle>;
14
+ children: ReactNode;
15
+ accessibilityLabel?: string;
16
+ };
17
+
18
+ export function adLabel(ad: AdFeedItem): string {
19
+ return ad.title || ad.body_text || "Platform ad";
20
+ }
21
+
22
+ export function LayoutShell({
23
+ ad, onPress, styles, contentStyle, children, accessibilityLabel,
24
+ }: LayoutShellProps) {
25
+ const indicatorState = useAdRotationIndicators();
26
+
27
+ return (
28
+ <View style={[styles.root, contentStyle]}>
29
+ <Pressable
30
+ onPress={onPress}
31
+ style={({ pressed }) => [shellContentStyle, pressed && styles.pressed]}
32
+ accessibilityRole="button"
33
+ accessibilityLabel={accessibilityLabel ?? adLabel(ad)}
34
+ >
35
+ {children}
36
+ </Pressable>
37
+ {indicatorState ? <AdRotationIndicators state={indicatorState} /> : null}
38
+ </View>
39
+ );
40
+ }
41
+
42
+ const shellContentStyle = { width: "100%" as const, position: "relative" as const };
43
+
44
+ export function AdTitle({ text, styles, numberOfLines }: { text?: string; styles: AdCreativeStyles; numberOfLines?: number }) {
45
+ if (!text) {
46
+ return null;
47
+ }
48
+ return <Text style={styles.title} numberOfLines={numberOfLines}>{text}</Text>;
49
+ }
50
+
51
+ export function AdBody({ text, styles, numberOfLines }: { text?: string; styles: AdCreativeStyles; numberOfLines?: number }) {
52
+ if (!text) {
53
+ return null;
54
+ }
55
+ return <Text style={styles.body} numberOfLines={numberOfLines}>{text}</Text>;
56
+ }
57
+
58
+ export function AdLinkCta({ styles, label = "Learn more" }: { styles: AdCreativeStyles; label?: string }) {
59
+ return <Text style={styles.link}>{label}</Text>;
60
+ }
61
+
62
+ export function AdBadge({ styles, label = "Ad" }: { styles: AdCreativeStyles; label?: string }) {
63
+ if (!styles.badge) {
64
+ return null;
65
+ }
66
+ return <Text style={styles.badge}>{label}</Text>;
67
+ }
68
+
69
+ export function AdImage({
70
+ uri, styles, resizeMode = "cover",
71
+ }: { uri?: string; styles: AdCreativeStyles; resizeMode?: "cover" | "contain" }) {
72
+ if (!uri) {
73
+ return null;
74
+ }
75
+ return (
76
+ <View style={styles.media}>
77
+ <Image source={{ uri }} style={styles.image} resizeMode={resizeMode} />
78
+ </View>
79
+ );
80
+ }