@adland/react 0.4.0 → 0.5.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @adland/react
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 2767844: fix: wrong adId casting
8
+
9
+ ## 0.5.0
10
+
11
+ ### Minor Changes
12
+
13
+ - c2df20a: Add `useFetch` hook with global in-memory cache and improve Ad component:
14
+
15
+ - Add `useFetch` hook with React Query-like global cache that persists across component lifecycles
16
+ - Add `fetchCache` utility for global cache management
17
+ - Improve Ad component to use relative URLs for local development
18
+ - Add loading and error states to Ad component
19
+ - Prevent loader flicker when switching tabs by using persistent cache
20
+
3
21
  ## 0.4.0
4
22
 
5
23
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adland/react",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -22,19 +22,21 @@
22
22
  "react-dom": "^18.0.0 || ^19.0.0"
23
23
  },
24
24
  "dependencies": {
25
- "@farcaster/miniapp-sdk": "0.2.1"
26
- },
27
- "devDependencies": {
25
+ "@adland/data": "^0.4.0",
26
+ "@farcaster/miniapp-sdk": "0.2.1",
28
27
  "@types/node": "^20",
29
28
  "@types/react": "^19.1.16",
30
29
  "@types/react-dom": "^18.3.1",
30
+ "lucide-react": "0.561.0",
31
+ "tsup": "^8.0.1"
32
+ },
33
+ "devDependencies": {
31
34
  "@typescript-eslint/eslint-plugin": "^7.13.1",
32
35
  "@typescript-eslint/parser": "^7.13.1",
33
36
  "@vercel/style-guide": "^6.0.0",
34
37
  "eslint": "^8",
35
38
  "eslint-config-turbo": "2.0.6",
36
39
  "eslint-plugin-only-warn": "^1.1.0",
37
- "tsup": "^8.0.1",
38
40
  "typescript": "^5.4.5"
39
41
  },
40
42
  "scripts": {
@@ -1,11 +1,23 @@
1
1
  import { useEffect, useRef, useCallback } from "react";
2
2
  import { sendTrackRequest } from "../utils/sdk";
3
+ import sdk from "@farcaster/miniapp-sdk";
4
+ import { adCardIcon } from "../utils/constants";
5
+ import { Loader, SquareDashed } from "lucide-react";
6
+ import { type AdData } from "@adland/data";
7
+ import { useFetch } from "../hooks/useFetch";
3
8
 
4
9
  /**
5
10
  * Get the base URL for the network
6
- * Currently only testnet is supported
11
+ * Uses relative URL in browser (for local dev), otherwise defaults to production
7
12
  */
8
- const NETWORK_BASE_URL = "https://testnet.adland.space";
13
+ const getBaseUrl = (): string => {
14
+ if (typeof window !== "undefined") {
15
+ // In browser - use relative URL for local development
16
+ return "";
17
+ }
18
+ // SSR fallback - use production URL
19
+ return "https://testnet.adland.space";
20
+ };
9
21
 
10
22
  export interface AdProps extends React.HTMLAttributes<HTMLDivElement> {
11
23
  /**
@@ -15,15 +27,15 @@ export interface AdProps extends React.HTMLAttributes<HTMLDivElement> {
15
27
  /**
16
28
  * Unique identifier for the ad
17
29
  */
18
- adId: string;
19
- /**
20
- * The content to display in the ad
21
- */
22
- children: React.ReactNode;
30
+ slotId: string;
23
31
  /**
24
32
  * Network to use for tracking requests (currently only "testnet" is supported)
25
33
  */
26
34
  network?: "testnet";
35
+ /**
36
+ * Optional base URL override. If not provided, uses relative URL in browser or production URL in SSR
37
+ */
38
+ baseUrl?: string;
27
39
  }
28
40
 
29
41
  /**
@@ -39,33 +51,56 @@ export interface AdProps extends React.HTMLAttributes<HTMLDivElement> {
39
51
  */
40
52
  export function Ad({
41
53
  owner,
42
- adId,
43
- children,
54
+ slotId,
44
55
  network = "testnet",
56
+ baseUrl,
45
57
  ...props
46
58
  }: AdProps) {
47
59
  const ref = useRef<HTMLDivElement>(null);
60
+ const {
61
+ data: adData,
62
+ isLoading,
63
+ error,
64
+ } = useFetch<AdData>(
65
+ `ad-data-${owner}-${slotId}`,
66
+ async () => {
67
+ const res = await fetch(
68
+ `${networkBaseUrl}/api/data/owner/${owner.toLowerCase()}/slot/${slotId}`,
69
+ {
70
+ method: "GET",
71
+ headers: {
72
+ "Content-Type": "application/json",
73
+ },
74
+ },
75
+ );
76
+ return res.json();
77
+ },
78
+ {
79
+ enabled: !!owner && !!slotId,
80
+ },
81
+ );
82
+ const networkBaseUrl = baseUrl ?? getBaseUrl();
48
83
 
49
84
  const send = useCallback(
50
85
  (type: "view" | "click") => {
51
- const trackEndpoint = `${NETWORK_BASE_URL}/api/analytics/track`;
86
+ const trackEndpoint = `${networkBaseUrl}/api/analytics/track`;
52
87
 
53
88
  sendTrackRequest(trackEndpoint, {
54
89
  type,
55
- adId,
90
+ adId: slotId,
56
91
  adOwner: owner,
57
92
  }).catch((error: unknown) => {
58
93
  console.error(`[@adland/react] Failed to track ${type}:`, error);
59
94
  });
60
95
  },
61
- [adId, owner],
96
+ [slotId, owner, networkBaseUrl],
62
97
  );
63
98
 
64
99
  useEffect(() => {
65
100
  const el = ref.current;
66
101
  if (!el) return;
67
102
 
68
- const key = `ad_view_${adId}`;
103
+ const key = `ad_view_${slotId}`;
69
104
 
70
105
  const obs = new IntersectionObserver(
71
106
  (entries) => {
@@ -89,16 +124,167 @@ export function Ad({
89
124
 
90
125
  obs.observe(el);
91
126
  return () => obs.disconnect();
92
- }, [adId, send]);
127
+ }, [slotId, send]);
93
128
 
94
129
  // CLICK tracking
95
- const onClick = useCallback(() => {
96
- send("click");
97
- }, [send]);
130
+ // Only track clicks that aren't on links or buttons (let those handle their own navigation)
131
+ const onClick = useCallback(
132
+ (e: React.MouseEvent<HTMLDivElement>) => {
133
+ // Don't track if clicking on a link, button, or other interactive element
134
+ const target = e.target as HTMLElement;
135
+ const isInteractiveElement =
136
+ target.tagName === "A" ||
137
+ target.tagName === "BUTTON" ||
138
+ target.closest("a") !== null ||
139
+ target.closest("button") !== null;
140
+
141
+ if (!isInteractiveElement) {
142
+ send("click");
143
+ }
144
+ },
145
+ [send],
146
+ );
98
147
 
99
148
  return (
100
149
  <div ref={ref} onClick={onClick} {...props}>
150
+ {(() => {
151
+ if (isLoading) {
152
+ return <LoadingAdContent />;
153
+ }
154
+ if (error) {
155
+ return <ErrorAdContent error={error} />;
156
+ }
157
+ if (adData) {
158
+ return (
159
+ <>
160
+ {adData.type === "link" && <LinkAdContent data={adData.data} />}
161
+
162
+ {adData.type === "cast" && <CastAdContent data={adData.data} />}
163
+
164
+ {adData.type === "miniapp" && (
165
+ <MiniAppAdContent data={adData.data} />
166
+ )}
167
+ </>
168
+ );
169
+ } else {
170
+ return (
171
+ <EmtpyAdContent
172
+ data={{ url: networkBaseUrl + `${owner}/${slotId}` }}
173
+ />
174
+ );
175
+ }
176
+ })()}
177
+ </div>
178
+ );
179
+ }
180
+
181
+ const BasicAdBody = ({
182
+ children,
183
+ ...rest
184
+ }: { children: React.ReactNode } & React.HTMLAttributes<HTMLDivElement>) => {
185
+ return (
186
+ <div
187
+ className="border p-2 md:p-4 flex hover:cursor-pointer hover:bg-primary/10 flex-row rounded-md justify-between items-center gap-2 relative border-b-2 border-b-primary"
188
+ {...rest}
189
+ >
101
190
  {children}
191
+ <div className="inline-flex items-center border p-1 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-primary text-primary-foreground hover:bg-primary/80">
192
+ AD
193
+ </div>
102
194
  </div>
103
195
  );
196
+ };
197
+
198
+ function ErrorAdContent({ error }: { error: unknown }) {
199
+ return (
200
+ <BasicAdBody>
201
+ <div className="flex flex-row items-center gap-2">
202
+ <SquareDashed className="w-5 h-5 md:w-7 md:h-7" />
203
+ <p className="font-bold text-primary">ERROR</p>
204
+ </div>
205
+ </BasicAdBody>
206
+ );
207
+ }
208
+
209
+ function LoadingAdContent() {
210
+ return (
211
+ <BasicAdBody>
212
+ <div className="flex flex-row items-center gap-2">
213
+ <Loader className="w-5 h-5 md:w-7 md:h-7 animate-spin" />
214
+ <p className="font-bold text-primary">LOADING...</p>
215
+ </div>
216
+ </BasicAdBody>
217
+ );
218
+ }
219
+
220
+ function EmtpyAdContent({ data }: { data: { url: string } }) {
221
+ return (
222
+ <BasicAdBody
223
+ onClick={() => {
224
+ sdk.actions.openMiniApp({
225
+ url: data.url,
226
+ });
227
+ }}
228
+ >
229
+ <div className="flex flex-row items-center gap-2">
230
+ <SquareDashed className="w-5 h-5 md:w-7 md:h-7" />
231
+ <p className="font-bold text-primary">NO AD</p>
232
+ </div>
233
+ </BasicAdBody>
234
+ );
235
+ }
236
+
237
+ // Link Ad Component
238
+ function LinkAdContent({ data }: { data: { url: string } }) {
239
+ const Icon = adCardIcon["link"];
240
+ return (
241
+ <BasicAdBody
242
+ onClick={() => {
243
+ sdk.actions.openUrl(data.url);
244
+ }}
245
+ >
246
+ <div className="flex flex-row items-center gap-2">
247
+ <Icon className="w-5 h-5 md:w-7 md:h-7" />
248
+ <p className="font-bold text-primary">LINK</p>
249
+ </div>
250
+ </BasicAdBody>
251
+ );
252
+ }
253
+
254
+ // Cast Ad Component
255
+ function CastAdContent({ data }: { data: { hash: string } }) {
256
+ const Icon = adCardIcon["cast"];
257
+ return (
258
+ <BasicAdBody
259
+ onClick={() => {
260
+ sdk.actions.viewCast({
261
+ hash: data.hash,
262
+ });
263
+ }}
264
+ >
265
+ <div className="flex flex-row items-center gap-2">
266
+ <Icon className="w-5 h-5 md:w-7 md:h-7" />
267
+ <p className="font-bold text-primary">CAST</p>
268
+ </div>
269
+ </BasicAdBody>
270
+ );
271
+ }
272
+
273
+ // MiniApp Ad Component
274
+ function MiniAppAdContent({ data }: { data: { url: string } }) {
275
+ const Icon = adCardIcon["miniapp"];
276
+ return (
277
+ <BasicAdBody
278
+ onClick={() => {
279
+ sdk.actions.openMiniApp({
280
+ url: data.url,
281
+ });
282
+ }}
283
+ >
284
+ <div className="flex flex-row items-center gap-2">
285
+ <Icon className="w-5 h-5 md:w-7 md:h-7" />
286
+ <p className="font-bold text-primary">MINIAPP</p>
287
+ </div>
288
+ </BasicAdBody>
289
+ );
104
290
  }
@@ -0,0 +1,133 @@
1
+ import { useEffect, useState } from "react";
2
+ import FetchCache from "../utils/fetchCache";
3
+
4
+ type Status = "idle" | "loading" | "success" | "error";
5
+
6
+ const globalCache = new FetchCache();
7
+
8
+ export const fetchCache = {
9
+ clear: () => globalCache.clear(),
10
+ };
11
+
12
+ export function useFetch<T>(
13
+ key: string,
14
+ fetcher: () => Promise<T>,
15
+ opts?: {
16
+ enabled?: boolean;
17
+ ttl?: number; // ms
18
+ },
19
+ ) {
20
+ const { enabled = true, ttl = 0 } = opts ?? {};
21
+
22
+ // Get cached data from global cache
23
+ const getCachedData = (): T | null => {
24
+ return globalCache.get<T>(key, ttl || undefined);
25
+ };
26
+
27
+ const hasCachedData = () => {
28
+ return globalCache.has(key, ttl || undefined);
29
+ };
30
+
31
+ // Check if there's an active fetch for this key (dedupe concurrent requests)
32
+ const getActiveFetch = (): Promise<T> | undefined => {
33
+ return globalCache.getActiveFetch<T>(key);
34
+ };
35
+
36
+ const cachedData = getCachedData();
37
+ const [data, setData] = useState<T | null>(cachedData);
38
+ const [error, setError] = useState<unknown>(null);
39
+ // If we have cached data, start with success status to avoid showing loader
40
+ const [status, setStatus] = useState<Status>(cachedData ? "success" : "idle");
41
+
42
+ const refetch = async () => {
43
+ // ALWAYS check cache first - never show loading if we have valid cached data
44
+ const cached = getCachedData();
45
+ if (cached) {
46
+ // Already have valid cached data, ensure state is correct
47
+ setData(cached);
48
+ setStatus("success");
49
+ return cached;
50
+ }
51
+
52
+ // Check if there's already an active fetch for this key (dedupe)
53
+ const activeFetch = getActiveFetch();
54
+ if (activeFetch) {
55
+ try {
56
+ const res = await activeFetch;
57
+ setData(res);
58
+ setStatus("success");
59
+ return res;
60
+ } catch (e) {
61
+ setError(e);
62
+ setStatus("error");
63
+ throw e;
64
+ }
65
+ }
66
+
67
+ // Only set loading if we actually need to fetch
68
+ setStatus("loading");
69
+ setError(null);
70
+
71
+ try {
72
+ // Create fetch promise and store it for deduplication
73
+ const fetchPromise = fetcher();
74
+ globalCache.setActiveFetch(key, fetchPromise);
75
+
76
+ const res = await fetchPromise;
77
+ globalCache.set(key, res);
78
+ setData(res);
79
+ setStatus("success");
80
+ return res;
81
+ } catch (e) {
82
+ setError(e);
83
+ setStatus("error");
84
+ throw e;
85
+ }
86
+ };
87
+
88
+ useEffect(() => {
89
+ if (!enabled) return;
90
+
91
+ const cached = getCachedData();
92
+
93
+ if (cached) {
94
+ setData(cached);
95
+ setStatus("success");
96
+ return;
97
+ }
98
+
99
+ const activeFetch = getActiveFetch();
100
+ if (activeFetch) {
101
+ setStatus("loading");
102
+ activeFetch
103
+ .then((res) => {
104
+ setData(res);
105
+ setStatus("success");
106
+ })
107
+ .catch((e) => {
108
+ setError(e);
109
+ setStatus("error");
110
+ });
111
+ return;
112
+ }
113
+
114
+ if (status !== "loading" && status !== "success") {
115
+ refetch();
116
+ }
117
+ // eslint-disable-next-line react-hooks/exhaustive-deps
118
+ }, [key, enabled]);
119
+
120
+ const hasValidCache = hasCachedData();
121
+ const isLoading = status === "loading" && !hasValidCache;
122
+
123
+ return {
124
+ data,
125
+ error,
126
+ status,
127
+ isIdle: status === "idle",
128
+ isLoading,
129
+ isSuccess: status === "success",
130
+ isError: status === "error",
131
+ refetch,
132
+ };
133
+ }
package/src/index.ts CHANGED
@@ -8,3 +8,6 @@ export {
8
8
  sendTrackRequest,
9
9
  checkSdkActionsReady,
10
10
  } from "./utils/sdk";
11
+
12
+ // Constants exports
13
+ export { adCardIcon } from "./utils/constants";
@@ -0,0 +1,14 @@
1
+ import { AdType } from "@adland/data";
2
+ import { ForwardRefExoticComponent, RefAttributes } from "react";
3
+ import { Link, MessageCircle, LayoutGrid, LucideProps } from "lucide-react";
4
+
5
+ export const adCardIcon: Record<
6
+ AdType,
7
+ ForwardRefExoticComponent<
8
+ Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>
9
+ >
10
+ > = {
11
+ link: Link,
12
+ cast: MessageCircle,
13
+ miniapp: LayoutGrid,
14
+ };
@@ -0,0 +1,51 @@
1
+ class FetchCache {
2
+ private cache = new Map<string, { data: unknown; ts: number }>();
3
+ private activeFetches = new Map<string, Promise<unknown>>();
4
+
5
+ get<T>(key: string, ttl?: number): T | null {
6
+ const cached = this.cache.get(key);
7
+ if (!cached) return null;
8
+
9
+ if (ttl && Date.now() - cached.ts > ttl) {
10
+ // Cache expired, remove it
11
+ this.cache.delete(key);
12
+ return null;
13
+ }
14
+
15
+ return cached.data as T;
16
+ }
17
+
18
+ has(key: string, ttl?: number): boolean {
19
+ const cached = this.cache.get(key);
20
+ if (!cached) return false;
21
+
22
+ if (ttl && Date.now() - cached.ts > ttl) {
23
+ this.cache.delete(key);
24
+ return false;
25
+ }
26
+
27
+ return true;
28
+ }
29
+
30
+ set<T>(key: string, data: T): void {
31
+ this.cache.set(key, { data, ts: Date.now() });
32
+ }
33
+
34
+ getActiveFetch<T>(key: string): Promise<T> | undefined {
35
+ return this.activeFetches.get(key) as Promise<T> | undefined;
36
+ }
37
+
38
+ setActiveFetch<T>(key: string, promise: Promise<T>): void {
39
+ this.activeFetches.set(key, promise);
40
+ promise.finally(() => {
41
+ this.activeFetches.delete(key);
42
+ });
43
+ }
44
+
45
+ clear(): void {
46
+ this.cache.clear();
47
+ this.activeFetches.clear();
48
+ }
49
+ }
50
+
51
+ export default FetchCache;
package/src/utils/sdk.ts CHANGED
@@ -53,16 +53,74 @@ export async function sendTrackRequest(
53
53
  throw new Error("[@adland/react] SDK quickAuth is not available");
54
54
  }
55
55
 
56
- return await sdk.quickAuth.fetch(url, {
56
+ const requestOptions = {
57
57
  method: "POST",
58
58
  headers: {
59
59
  "Content-Type": "application/json",
60
60
  "x-auth-type": "farcaster",
61
61
  },
62
62
  body: JSON.stringify(data),
63
+ };
64
+
65
+ // Log request details for debugging
66
+ console.log("[@adland/react] Sending track request:", {
67
+ url,
68
+ method: requestOptions.method,
69
+ headers: requestOptions.headers,
70
+ body: data,
63
71
  });
72
+
73
+ const response = await sdk.quickAuth.fetch(url, requestOptions);
74
+
75
+ // Check if response is ok
76
+ if (!response.ok) {
77
+ const statusText = response.statusText;
78
+ const status = response.status;
79
+
80
+ // Try to get error body if available
81
+ let errorBody = null;
82
+ try {
83
+ const contentType = response.headers.get("content-type");
84
+ if (contentType?.includes("application/json")) {
85
+ errorBody = await response.clone().json();
86
+ } else {
87
+ errorBody = await response.clone().text();
88
+ }
89
+ } catch (e) {
90
+ // Ignore errors when reading response body
91
+ }
92
+
93
+ console.error("[@adland/react] Track request failed:", {
94
+ url,
95
+ status,
96
+ statusText,
97
+ errorBody,
98
+ requestBody: data,
99
+ });
100
+
101
+ throw new Error(
102
+ `[@adland/react] Track request failed: ${status} ${statusText}${errorBody ? ` - ${JSON.stringify(errorBody)}` : ""}`,
103
+ );
104
+ }
105
+
106
+ return response;
64
107
  } catch (error) {
65
- console.error("[@adland/react] Error sending track request:", error);
108
+ // Enhanced error logging
109
+ if (error instanceof Error) {
110
+ console.error("[@adland/react] Error sending track request:", {
111
+ message: error.message,
112
+ name: error.name,
113
+ stack: error.stack,
114
+ url,
115
+ requestData: data,
116
+ });
117
+ } else {
118
+ console.error("[@adland/react] Unknown error sending track request:", {
119
+ error,
120
+ url,
121
+ requestData: data,
122
+ });
123
+ }
66
124
  throw error;
67
125
  }
68
126
  }