@farcaster/snap 1.15.3 → 1.16.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.
Files changed (60) hide show
  1. package/dist/constants.d.ts +8 -0
  2. package/dist/constants.js +9 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/react/components/action-button.d.ts +2 -1
  6. package/dist/react/components/action-button.js +16 -3
  7. package/dist/react/components/badge.js +2 -3
  8. package/dist/react/index.d.ts +8 -1
  9. package/dist/react/index.js +9 -228
  10. package/dist/react/snap-view-core.d.ts +11 -0
  11. package/dist/react/snap-view-core.js +224 -0
  12. package/dist/react/v1/snap-view.d.ts +14 -0
  13. package/dist/react/v1/snap-view.js +9 -0
  14. package/dist/react/v2/snap-view.d.ts +21 -0
  15. package/dist/react/v2/snap-view.js +76 -0
  16. package/dist/react-native/components/snap-action-button.d.ts +1 -1
  17. package/dist/react-native/components/snap-action-button.js +19 -2
  18. package/dist/react-native/components/snap-badge.js +3 -3
  19. package/dist/react-native/index.d.ts +15 -43
  20. package/dist/react-native/index.js +10 -164
  21. package/dist/react-native/snap-view-core.d.ts +11 -0
  22. package/dist/react-native/snap-view-core.js +153 -0
  23. package/dist/react-native/types.d.ts +41 -0
  24. package/dist/react-native/types.js +1 -0
  25. package/dist/react-native/v1/snap-view.d.ts +22 -0
  26. package/dist/react-native/v1/snap-view.js +31 -0
  27. package/dist/react-native/v2/snap-view.d.ts +31 -0
  28. package/dist/react-native/v2/snap-view.js +101 -0
  29. package/dist/schemas.d.ts +15 -9
  30. package/dist/schemas.js +7 -8
  31. package/dist/server/parseRequest.d.ts +7 -0
  32. package/dist/server/parseRequest.js +27 -0
  33. package/dist/ui/catalog.d.ts +1 -0
  34. package/dist/ui/catalog.js +5 -2
  35. package/dist/ui/schema.js +1 -1
  36. package/dist/validator.d.ts +3 -2
  37. package/dist/validator.js +193 -2
  38. package/llms.txt +9 -0
  39. package/package.json +1 -1
  40. package/src/constants.ts +11 -1
  41. package/src/index.ts +8 -0
  42. package/src/react/accent-context.tsx +1 -1
  43. package/src/react/components/action-button.tsx +25 -3
  44. package/src/react/components/badge.tsx +2 -3
  45. package/src/react/index.tsx +36 -327
  46. package/src/react/snap-view-core.tsx +340 -0
  47. package/src/react/v1/snap-view.tsx +50 -0
  48. package/src/react/v2/snap-view.tsx +168 -0
  49. package/src/react-native/components/snap-action-button.tsx +26 -4
  50. package/src/react-native/components/snap-badge.tsx +3 -3
  51. package/src/react-native/index.tsx +47 -263
  52. package/src/react-native/snap-view-core.tsx +209 -0
  53. package/src/react-native/types.ts +37 -0
  54. package/src/react-native/v1/snap-view.tsx +108 -0
  55. package/src/react-native/v2/snap-view.tsx +239 -0
  56. package/src/schemas.ts +9 -10
  57. package/src/server/parseRequest.ts +39 -0
  58. package/src/ui/catalog.ts +5 -2
  59. package/src/ui/schema.ts +1 -1
  60. package/src/validator.ts +240 -2
@@ -0,0 +1,340 @@
1
+ "use client";
2
+
3
+ import type { Spec } from "@json-render/core";
4
+ import { snapJsonRenderCatalog } from "../ui/index.js";
5
+ import { SnapCatalogView } from "./catalog-renderer";
6
+ import { SnapPreviewAccentProvider } from "./accent-context";
7
+ import { resolveSnapPaletteHex } from "./lib/resolve-palette-hex";
8
+ import { snapPreviewPrimaryCssProperties } from "./lib/preview-primary-css";
9
+ import {
10
+ type CSSProperties,
11
+ useCallback,
12
+ useEffect,
13
+ useMemo,
14
+ useRef,
15
+ useState,
16
+ } from "react";
17
+ import type { JsonValue, SnapActionHandlers, SnapPage } from "./index";
18
+
19
+ // ─── Internal helpers ──────────────────────────────────
20
+
21
+ export function applyStatePaths(
22
+ model: Record<string, unknown>,
23
+ changes: { path: string; value: unknown }[] | Record<string, unknown>,
24
+ ): void {
25
+ const entries = Array.isArray(changes)
26
+ ? changes.map((c) => [c.path, c.value] as const)
27
+ : Object.entries(changes);
28
+ for (const [path, value] of entries) {
29
+ const trimmed = path.startsWith("/") ? path : `/${path}`;
30
+ const parts = trimmed.split("/").filter(Boolean);
31
+ if (parts.length < 2) continue;
32
+ const [top, ...rest] = parts;
33
+ if (top === "inputs") {
34
+ if (typeof model.inputs !== "object" || model.inputs === null) {
35
+ model.inputs = {};
36
+ }
37
+ const inputs = model.inputs as Record<string, unknown>;
38
+ if (rest.length === 1) {
39
+ inputs[rest[0]!] = value;
40
+ }
41
+ continue;
42
+ }
43
+ if (top === "theme") {
44
+ if (typeof model.theme !== "object" || model.theme === null) {
45
+ model.theme = {};
46
+ }
47
+ const theme = model.theme as Record<string, unknown>;
48
+ if (rest.length === 1) {
49
+ theme[rest[0]!] = value;
50
+ }
51
+ }
52
+ }
53
+ }
54
+
55
+ const CONFETTI_COLORS = [
56
+ "#8B5CF6",
57
+ "#EC4899",
58
+ "#3B82F6",
59
+ "#10B981",
60
+ "#F59E0B",
61
+ "#EF4444",
62
+ "#06B6D4",
63
+ ];
64
+
65
+ function ConfettiOverlay() {
66
+ const pieces = useMemo(
67
+ () =>
68
+ Array.from({ length: 80 }, (_, i) => ({
69
+ id: i,
70
+ left: Math.random() * 100,
71
+ delay: Math.random() * 1.2,
72
+ duration: 2.5 + Math.random() * 2,
73
+ color:
74
+ CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
75
+ size: 6 + Math.random() * 8,
76
+ rotation: Math.random() * 360,
77
+ })),
78
+ [],
79
+ );
80
+
81
+ return (
82
+ <div
83
+ style={{
84
+ position: "absolute",
85
+ inset: 0,
86
+ overflow: "hidden",
87
+ pointerEvents: "none",
88
+ zIndex: 20,
89
+ }}
90
+ >
91
+ {pieces.map(({ id, left, delay, duration, color, size, rotation }) => (
92
+ <div
93
+ key={id}
94
+ style={{
95
+ position: "absolute",
96
+ left: `${left}%`,
97
+ top: -20,
98
+ width: size,
99
+ height: size * 0.6,
100
+ backgroundColor: color,
101
+ borderRadius: 2,
102
+ transform: `rotate(${rotation}deg)`,
103
+ animation: `confettiFall ${duration}s ease-in ${delay}s forwards`,
104
+ }}
105
+ />
106
+ ))}
107
+ <style>{`@keyframes confettiFall{0%{top:-20px;opacity:1;transform:rotate(0deg) translateX(0)}50%{opacity:1}100%{top:110%;opacity:0;transform:rotate(720deg) translateX(${
108
+ Math.random() > 0.5 ? "" : "-"
109
+ }40px)}}`}</style>
110
+ </div>
111
+ );
112
+ }
113
+
114
+ function SnapLoadingOverlay({
115
+ appearance,
116
+ accentHex,
117
+ active,
118
+ }: {
119
+ appearance: "light" | "dark";
120
+ accentHex: string;
121
+ active: boolean;
122
+ }) {
123
+ const isDark = appearance === "dark";
124
+ const tint = isDark ? "rgba(0, 0, 0, 0.1)" : "rgba(255, 255, 255, 0.2)";
125
+ const trackColor = isDark
126
+ ? "rgba(255, 255, 255, 0.12)"
127
+ : "rgba(15, 23, 42, 0.1)";
128
+
129
+ return (
130
+ <div
131
+ style={{
132
+ position: "absolute",
133
+ inset: 0,
134
+ display: "flex",
135
+ alignItems: "center",
136
+ justifyContent: "center",
137
+ zIndex: 10,
138
+ background: tint,
139
+ backdropFilter: active ? "blur(10px) saturate(1.05)" : "none",
140
+ WebkitBackdropFilter: active
141
+ ? "blur(10px) saturate(1.05)"
142
+ : "none",
143
+ opacity: active ? 1 : 0,
144
+ pointerEvents: active ? "auto" : "none",
145
+ transition: "opacity 0.28s ease, backdrop-filter 0.28s ease",
146
+ }}
147
+ aria-hidden={!active}
148
+ aria-busy={active ? true : undefined}
149
+ aria-live={active ? "polite" : undefined}
150
+ aria-label={active ? "Loading" : undefined}
151
+ >
152
+ <div
153
+ data-snap-loading-spinner
154
+ style={{
155
+ width: 30,
156
+ height: 30,
157
+ borderRadius: "50%",
158
+ border: `2.5px solid ${trackColor}`,
159
+ borderTopColor: accentHex,
160
+ opacity: 0.88,
161
+ animation: "snapViewSpin 0.75s linear infinite",
162
+ flexShrink: 0,
163
+ }}
164
+ />
165
+ <style>{`
166
+ @keyframes snapViewSpin {
167
+ to { transform: rotate(360deg); }
168
+ }
169
+ @media (prefers-reduced-motion: reduce) {
170
+ [data-snap-loading-spinner] {
171
+ animation: none;
172
+ border-top-color: ${accentHex};
173
+ opacity: 0.75;
174
+ }
175
+ }
176
+ `}</style>
177
+ </div>
178
+ );
179
+ }
180
+
181
+ const PALETTE = [
182
+ "gray",
183
+ "blue",
184
+ "red",
185
+ "amber",
186
+ "green",
187
+ "teal",
188
+ "purple",
189
+ "pink",
190
+ ] as const;
191
+
192
+ // ─── SnapViewCore ────────────────────────────────────
193
+ // Shared rendering logic used by both v1 and v2.
194
+
195
+ export function SnapViewCore({
196
+ snap,
197
+ handlers,
198
+ loading = false,
199
+ appearance = "dark",
200
+ }: {
201
+ snap: SnapPage;
202
+ handlers: SnapActionHandlers;
203
+ loading?: boolean;
204
+ appearance?: "light" | "dark";
205
+ }) {
206
+ const spec = snap.ui;
207
+ const initialState = useMemo(() => spec.state ?? { inputs: {} }, [spec]);
208
+
209
+ const stateRef = useRef<Record<string, unknown>>(initialState);
210
+
211
+ useEffect(() => {
212
+ stateRef.current = {
213
+ inputs: {
214
+ ...((initialState.inputs ?? {}) as Record<string, unknown>),
215
+ },
216
+ theme: {
217
+ ...((initialState.theme ?? {}) as Record<string, unknown>),
218
+ },
219
+ };
220
+ }, [initialState]);
221
+
222
+ useEffect(() => {
223
+ const catalogResult = snapJsonRenderCatalog.validate(spec);
224
+ if (!catalogResult.success) {
225
+ // eslint-disable-next-line no-console
226
+ console.warn("[Snap] catalog validation issues:", catalogResult.error);
227
+ }
228
+ }, [spec]);
229
+
230
+ const [pageKey, setPageKey] = useState(0);
231
+ useEffect(() => {
232
+ setPageKey((k) => k + 1);
233
+ }, [spec]);
234
+
235
+ const showConfetti = snap.effects?.includes("confetti");
236
+
237
+ const accentName = snap.theme?.accent ?? "purple";
238
+
239
+ const accentHex = useMemo(
240
+ () => resolveSnapPaletteHex(accentName, appearance),
241
+ [accentName, appearance],
242
+ );
243
+
244
+ const previewSurfaceStyle = useMemo(() => {
245
+ const vars: Record<string, string> = {};
246
+ for (const c of PALETTE)
247
+ vars[`--snap-color-${c}`] = resolveSnapPaletteHex(c, appearance);
248
+ return {
249
+ ...snapPreviewPrimaryCssProperties(accentName, appearance),
250
+ ...vars,
251
+ } as CSSProperties;
252
+ }, [accentName, appearance]);
253
+
254
+ const handleAction = useCallback(
255
+ (name: unknown, params: unknown) => {
256
+ const inputs = (stateRef.current.inputs ?? {}) as Record<
257
+ string,
258
+ JsonValue
259
+ >;
260
+ const p = (params ?? {}) as Record<string, unknown>;
261
+ switch (name) {
262
+ case "submit":
263
+ handlers.submit(String(p.target ?? ""), inputs);
264
+ break;
265
+ case "open_url":
266
+ handlers.open_url(String(p.target ?? ""));
267
+ break;
268
+ case "open_mini_app":
269
+ handlers.open_mini_app(String(p.target ?? ""));
270
+ break;
271
+ case "view_cast":
272
+ handlers.view_cast({ hash: String(p.hash ?? "") });
273
+ break;
274
+ case "view_profile":
275
+ handlers.view_profile({ fid: Number(p.fid ?? 0) });
276
+ break;
277
+ case "compose_cast":
278
+ handlers.compose_cast({
279
+ text: p.text ? String(p.text) : undefined,
280
+ channelKey: p.channelKey ? String(p.channelKey) : undefined,
281
+ embeds: Array.isArray(p.embeds)
282
+ ? (p.embeds as string[])
283
+ : undefined,
284
+ });
285
+ break;
286
+ case "view_token":
287
+ handlers.view_token({ token: String(p.token ?? "") });
288
+ break;
289
+ case "send_token":
290
+ handlers.send_token({
291
+ token: String(p.token ?? ""),
292
+ amount: p.amount ? String(p.amount) : undefined,
293
+ recipientFid: p.recipientFid ? Number(p.recipientFid) : undefined,
294
+ recipientAddress: p.recipientAddress
295
+ ? String(p.recipientAddress)
296
+ : undefined,
297
+ });
298
+ break;
299
+ case "swap_token":
300
+ handlers.swap_token({
301
+ sellToken: p.sellToken ? String(p.sellToken) : undefined,
302
+ buyToken: p.buyToken ? String(p.buyToken) : undefined,
303
+ });
304
+ break;
305
+ default:
306
+ break;
307
+ }
308
+ },
309
+ [handlers],
310
+ );
311
+
312
+ return (
313
+ <div style={{ position: "relative", width: "100%" }}>
314
+ {showConfetti && <ConfettiOverlay />}
315
+ <SnapLoadingOverlay
316
+ appearance={appearance}
317
+ accentHex={accentHex}
318
+ active={loading}
319
+ />
320
+
321
+ <div style={previewSurfaceStyle}>
322
+ <SnapPreviewAccentProvider
323
+ pageAccent={snap.theme?.accent}
324
+ appearance={appearance}
325
+ >
326
+ <SnapCatalogView
327
+ key={pageKey}
328
+ spec={spec}
329
+ state={initialState}
330
+ loading={false}
331
+ onStateChange={(changes) => {
332
+ applyStatePaths(stateRef.current, changes);
333
+ }}
334
+ onAction={handleAction}
335
+ />
336
+ </SnapPreviewAccentProvider>
337
+ </div>
338
+ </div>
339
+ );
340
+ }
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import { SnapViewCore } from "../snap-view-core";
4
+ import type { SnapPage, SnapActionHandlers } from "../index";
5
+
6
+ export function SnapViewV1({
7
+ snap,
8
+ handlers,
9
+ loading = false,
10
+ appearance = "dark",
11
+ }: {
12
+ snap: SnapPage;
13
+ handlers: SnapActionHandlers;
14
+ loading?: boolean;
15
+ appearance?: "light" | "dark";
16
+ }) {
17
+ return (
18
+ <SnapViewCore
19
+ snap={snap}
20
+ handlers={handlers}
21
+ loading={loading}
22
+ appearance={appearance}
23
+ />
24
+ );
25
+ }
26
+
27
+ export function SnapCardV1({
28
+ snap,
29
+ handlers,
30
+ loading = false,
31
+ appearance = "dark",
32
+ maxWidth = 480,
33
+ }: {
34
+ snap: SnapPage;
35
+ handlers: SnapActionHandlers;
36
+ loading?: boolean;
37
+ appearance?: "light" | "dark";
38
+ maxWidth?: number;
39
+ }) {
40
+ return (
41
+ <div style={{ position: "relative", width: "100%", maxWidth }}>
42
+ <SnapViewV1
43
+ snap={snap}
44
+ handlers={handlers}
45
+ loading={loading}
46
+ appearance={appearance}
47
+ />
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,168 @@
1
+ "use client";
2
+
3
+ import { type ReactNode, useEffect, useMemo } from "react";
4
+ import { validateSnapResponse } from "../../validator.js";
5
+ import type { ValidationResult } from "../../validator.js";
6
+ import { SnapViewCore } from "../snap-view-core";
7
+ import type { SnapPage, SnapActionHandlers } from "../index";
8
+
9
+ const SNAP_MAX_HEIGHT = 500;
10
+ const SNAP_WARNING_HEIGHT = 700;
11
+
12
+ // ─── Default validation error fallback ────────────────
13
+
14
+ function SnapValidationFallback({
15
+ appearance,
16
+ message,
17
+ }: {
18
+ appearance: "light" | "dark";
19
+ message?: string;
20
+ }) {
21
+ const isDark = appearance === "dark";
22
+ return (
23
+ <div
24
+ style={{
25
+ width: "100%",
26
+ padding: 16,
27
+ display: "flex",
28
+ alignItems: "center",
29
+ justifyContent: "center",
30
+ color: isDark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.4)",
31
+ fontSize: 14,
32
+ }}
33
+ >
34
+ <span>{message ? `Unable to render snap: ${message}` : "Unable to render snap"}</span>
35
+ </div>
36
+ );
37
+ }
38
+
39
+ // ─── SnapViewV2 ──────────────────────────────────────
40
+
41
+ export function SnapViewV2({
42
+ snap,
43
+ handlers,
44
+ loading = false,
45
+ appearance = "dark",
46
+ onValidationError,
47
+ validationErrorFallback,
48
+ }: {
49
+ snap: SnapPage;
50
+ handlers: SnapActionHandlers;
51
+ loading?: boolean;
52
+ appearance?: "light" | "dark";
53
+ onValidationError?: (result: ValidationResult) => void;
54
+ validationErrorFallback?: ReactNode;
55
+ }) {
56
+ const validation = useMemo(() => validateSnapResponse(snap), [snap]);
57
+ const valid = validation.valid;
58
+ const validationMessage = validation.issues[0]?.message;
59
+
60
+ useEffect(() => {
61
+ if (!valid) {
62
+ if (onValidationError) {
63
+ onValidationError(validation);
64
+ } else {
65
+ // eslint-disable-next-line no-console
66
+ console.warn("[Snap] validation issues:", validation.issues);
67
+ }
68
+ }
69
+ }, [valid, validation, onValidationError]);
70
+
71
+ if (!valid) {
72
+ if (validationErrorFallback === null) return null;
73
+ return <>{validationErrorFallback ?? <SnapValidationFallback appearance={appearance} message={validationMessage} />}</>;
74
+ }
75
+
76
+ return (
77
+ <SnapViewCore
78
+ snap={snap}
79
+ handlers={handlers}
80
+ loading={loading}
81
+ appearance={appearance}
82
+ />
83
+ );
84
+ }
85
+
86
+ // ─── SnapCardV2 ──────────────────────────────────────
87
+
88
+ export function SnapCardV2({
89
+ snap,
90
+ handlers,
91
+ loading = false,
92
+ appearance = "dark",
93
+ maxWidth = 480,
94
+ showOverflowWarning = false,
95
+ onValidationError,
96
+ validationErrorFallback,
97
+ }: {
98
+ snap: SnapPage;
99
+ handlers: SnapActionHandlers;
100
+ loading?: boolean;
101
+ appearance?: "light" | "dark";
102
+ maxWidth?: number;
103
+ showOverflowWarning?: boolean;
104
+ onValidationError?: (result: ValidationResult) => void;
105
+ validationErrorFallback?: ReactNode;
106
+ }) {
107
+ const maxHeight = showOverflowWarning ? SNAP_WARNING_HEIGHT : SNAP_MAX_HEIGHT;
108
+ const bg = appearance === "dark" ? "rgba(0,0,0,0.85)" : "rgba(255,255,255,0.9)";
109
+
110
+ return (
111
+ <div
112
+ style={{
113
+ position: "relative",
114
+ width: "100%",
115
+ maxWidth,
116
+ maxHeight,
117
+ overflow: "hidden",
118
+ }}
119
+ >
120
+ <SnapViewV2
121
+ snap={snap}
122
+ handlers={handlers}
123
+ loading={loading}
124
+ appearance={appearance}
125
+ onValidationError={onValidationError}
126
+ validationErrorFallback={validationErrorFallback}
127
+ />
128
+ {showOverflowWarning && (
129
+ <div
130
+ style={{
131
+ position: "absolute",
132
+ top: SNAP_MAX_HEIGHT,
133
+ left: 0,
134
+ right: 0,
135
+ bottom: 0,
136
+ pointerEvents: "none",
137
+ zIndex: 10,
138
+ }}
139
+ >
140
+ <div style={{ borderTop: "1px dashed rgba(255,100,100,0.6)", position: "relative" }}>
141
+ <span
142
+ style={{
143
+ position: "absolute",
144
+ top: -10,
145
+ right: 0,
146
+ fontSize: 10,
147
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
148
+ color: "rgba(255,100,100,0.7)",
149
+ background: bg,
150
+ padding: "1px 4px",
151
+ borderRadius: 3,
152
+ }}
153
+ >
154
+ {SNAP_MAX_HEIGHT}px
155
+ </span>
156
+ </div>
157
+ <div
158
+ style={{
159
+ height: "100%",
160
+ background:
161
+ "repeating-linear-gradient(-45deg, transparent, transparent 8px, rgba(255,100,100,0.06) 8px, rgba(255,100,100,0.06) 16px)",
162
+ }}
163
+ />
164
+ </div>
165
+ )}
166
+ </div>
167
+ );
168
+ }
@@ -2,16 +2,29 @@ declare const __DEV__: boolean;
2
2
 
3
3
  import type { ComponentRenderProps } from "@json-render/react-native";
4
4
  import { Pressable, StyleSheet, Text, View } from "react-native";
5
+ import { ExternalLink } from "lucide-react-native";
5
6
  import { useSnapPalette } from "../use-snap-palette";
6
7
  import { useSnapTheme } from "../theme";
7
8
  import { ICON_MAP } from "./snap-icon";
8
9
 
10
+ function isExternalLinkAction(
11
+ on: Record<string, unknown> | undefined,
12
+ ): boolean {
13
+ if (!on) return false;
14
+ const press = on.press as
15
+ | { action?: string; params?: Record<string, unknown> }
16
+ | undefined;
17
+ if (!press || press.action !== "open_url") return false;
18
+ return press.params?.isSnap !== true;
19
+ }
20
+
9
21
  export function SnapActionButton({
10
- element: { props },
22
+ element,
11
23
  emit,
12
24
  }: ComponentRenderProps<Record<string, unknown>>) {
13
25
  const { accentHex } = useSnapPalette();
14
26
  const { colors } = useSnapTheme();
27
+ const { props } = element;
15
28
  const label = String(props.label ?? "Action");
16
29
  const variant = String(props.variant ?? "secondary");
17
30
  const isPrimary = variant === "primary";
@@ -20,6 +33,9 @@ export function SnapActionButton({
20
33
  const textColor = isPrimary ? "#fff" : colors.text;
21
34
  const iconColor = isPrimary ? "#fff" : colors.text;
22
35
 
36
+ const on = (element as unknown as { on?: Record<string, unknown> }).on;
37
+ const showExternalIcon = isExternalLinkAction(on);
38
+
23
39
  return (
24
40
  <View style={styles.outer}>
25
41
  <Pressable
@@ -43,12 +59,18 @@ export function SnapActionButton({
43
59
  })();
44
60
  }}
45
61
  >
46
- {iconName && ICON_MAP[iconName] ? (
47
- (() => { const I = ICON_MAP[iconName]!; return <I size={16} color={iconColor} />; })()
48
- ) : null}
62
+ {iconName && ICON_MAP[iconName]
63
+ ? (() => {
64
+ const I = ICON_MAP[iconName]!;
65
+ return <I size={16} color={iconColor} />;
66
+ })()
67
+ : null}
49
68
  <Text style={{ color: textColor, fontSize: 14, fontWeight: "600" }}>
50
69
  {label}
51
70
  </Text>
71
+ {showExternalIcon ? (
72
+ <ExternalLink size={14} color={iconColor} style={{ opacity: 0.6 }} />
73
+ ) : null}
52
74
  </Pressable>
53
75
  </View>
54
76
  );
@@ -22,17 +22,17 @@ export function SnapBadge({
22
22
  style={[
23
23
  styles.badge,
24
24
  isFilled
25
- ? { backgroundColor: resolvedColor, borderColor: resolvedColor }
25
+ ? { backgroundColor: resolvedColor + "20", borderColor: "transparent" }
26
26
  : { borderColor: resolvedColor },
27
27
  ]}
28
28
  >
29
29
  {Icon && (
30
- <Icon size={12} color={isFilled ? "#fff" : resolvedColor} />
30
+ <Icon size={12} color={resolvedColor} />
31
31
  )}
32
32
  <Text
33
33
  style={[
34
34
  styles.label,
35
- { color: isFilled ? "#fff" : resolvedColor },
35
+ { color: resolvedColor },
36
36
  ]}
37
37
  >
38
38
  {label}