@cartbase/storefront 0.7.0 → 0.9.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.
@@ -131,6 +131,18 @@ export function applyConsent(choices: ConsentChoices): void {
131
131
  if (choices.ads) w.ttq.grantConsent()
132
132
  else w.ttq.revokeConsent()
133
133
  }
134
+
135
+ // ChatGPT Ads. One command on their own queue, `oaiq("consent", bool)`,
136
+ // which works before the SDK has arrived because the queue buffers it.
137
+ //
138
+ // A grant also fires the page view the loader withheld: their docs say
139
+ // an unconsented event sends no ping and do not promise that a held call
140
+ // is released later, so the visitor who accepts on their third page is
141
+ // counted from the grant onward rather than not at all.
142
+ if (typeof w.oaiq === "function") {
143
+ w.oaiq("consent", choices.ads)
144
+ if (choices.ads) w.oaiq("measure", "page_viewed", { type: "contents" })
145
+ }
134
146
  }
135
147
 
136
148
  /**
@@ -24,6 +24,14 @@ import {
24
24
  trackTikTokPurchase,
25
25
  trackTikTokViewContent,
26
26
  } from "./ttq"
27
+ import {
28
+ minorUnits,
29
+ trackOpenAiCheckoutStarted,
30
+ trackOpenAiContentsViewed,
31
+ trackOpenAiItemsAdded,
32
+ trackOpenAiOrderCreated,
33
+ type OpenAiContentItem,
34
+ } from "./oaiq"
27
35
  import { googleAdsPurchaseSendTo, trackGoogleAdsPurchase } from "./google-ads"
28
36
  import type { TrackingConfig } from "./types"
29
37
 
@@ -118,6 +126,25 @@ const tiktokContents = (lines: TrackedLine[]) =>
118
126
  const unitCount = (lines: TrackedLine[]) =>
119
127
  lines.reduce((sum, line) => sum + (Number(line.quantity) || 0), 0)
120
128
 
129
+ /**
130
+ * ChatGPT Ads contents. The shape is close to TikTok's, but every amount
131
+ * is an INTEGER IN MINOR UNITS on this vendor, which is why the mapping
132
+ * cannot be shared with `tiktokContents` above: the same numbers in the
133
+ * same field names would be a hundredfold under-report.
134
+ */
135
+ const openAiContents = (
136
+ lines: TrackedLine[],
137
+ currency: string
138
+ ): OpenAiContentItem[] =>
139
+ lines.map((line) => ({
140
+ id: line.productId,
141
+ name: line.title,
142
+ content_type: "product",
143
+ quantity: line.quantity,
144
+ amount: minorUnits(line.price),
145
+ currency,
146
+ }))
147
+
121
148
  /** Product page view. */
122
149
  export function trackProductView(input: {
123
150
  line: TrackedLine
@@ -137,6 +164,12 @@ export function trackProductView(input: {
137
164
  currency,
138
165
  value,
139
166
  })
167
+ trackOpenAiContentsViewed({
168
+ contentId: line.productId,
169
+ contentName: line.title,
170
+ currency,
171
+ value,
172
+ })
140
173
  trackGAViewItem({ currency, value, items: gaItems([line], currency) })
141
174
  trackRybbitViewItem({
142
175
  item_id: line.variantId || line.productId,
@@ -168,6 +201,14 @@ export function trackCartAdd(input: {
168
201
  currency,
169
202
  value,
170
203
  })
204
+ trackOpenAiItemsAdded({
205
+ contentId: line.productId,
206
+ contentName: line.title,
207
+ quantity: line.quantity,
208
+ price: line.price,
209
+ currency,
210
+ value,
211
+ })
171
212
  trackGAAddToCart({ currency, value, items: gaItems([line], currency) })
172
213
  trackRybbitAddToCart({
173
214
  item_id: line.variantId || line.productId,
@@ -199,6 +240,11 @@ export function trackCheckoutStart(input: {
199
240
  currency,
200
241
  value,
201
242
  })
243
+ trackOpenAiCheckoutStarted({
244
+ contents: openAiContents(lines, currency),
245
+ currency,
246
+ value,
247
+ })
202
248
  trackGABeginCheckout({
203
249
  currency,
204
250
  value,
@@ -222,8 +268,9 @@ export function trackCheckoutStart(input: {
222
268
  * malformed `send_to` is accepted by Google and silently dropped.
223
269
  *
224
270
  * Everything here is deduped against the server: Meta on
225
- * `purchase_<displayId>`, TikTok on `tt_purchase_<displayId>`, GA4 and
226
- * Google Ads on the transaction id, which is `displayId` itself.
271
+ * `purchase_<displayId>`, TikTok on `tt_purchase_<displayId>`, ChatGPT Ads
272
+ * on `oai_order_<displayId>`, GA4 and Google Ads on the transaction id,
273
+ * which is `displayId` itself.
227
274
  */
228
275
  export function trackOrderPurchase(
229
276
  order: TrackedOrder,
@@ -261,6 +308,13 @@ export function trackOrderPurchase(
261
308
  customerType,
262
309
  })
263
310
 
311
+ trackOpenAiOrderCreated({
312
+ contents: openAiContents(lines, currency),
313
+ currency,
314
+ value,
315
+ displayId,
316
+ })
317
+
264
318
  trackGAPurchase({
265
319
  transaction_id: transactionId,
266
320
  currency,
@@ -4,6 +4,14 @@ import { getTrackingConfig } from "./get-tracking-config"
4
4
  import type { StorefrontClient } from "../api/http"
5
5
  import type { TrackingAttribution, TrackingClientHints } from "./types"
6
6
  import { CLICK_ID_METADATA_KEYS } from "./attribution"
7
+ import {
8
+ LEGACY_UTM_FIRST_COOKIE,
9
+ LEGACY_UTM_LAST_COOKIE,
10
+ LEGACY_VISITOR_COOKIE,
11
+ UTM_FIRST_COOKIE,
12
+ UTM_LAST_COOKIE,
13
+ VISITOR_COOKIE,
14
+ } from "../lib/cookie-names"
7
15
 
8
16
  /**
9
17
  * Reads Meta + GA4 attribution signals from the current Next.js server
@@ -73,14 +81,19 @@ export async function getTrackingAttribution(
73
81
  // attribution.ts on first tracking call. Surfaces server-side here
74
82
  // so the order.placed CAPI Purchase can include it as external_id
75
83
  // (alongside customer_id when both exist — Meta accepts an array).
76
- anonId = cookieStore.get("_1c_anon")?.value
84
+ anonId =
85
+ cookieStore.get(VISITOR_COOKIE)?.value ?? cookieStore.get(LEGACY_VISITOR_COOKIE)?.value
77
86
  gaCookieRaw = cookieStore.get("_ga")?.value
78
87
  // _1c_utm_first / _1c_utm_last — JSON-encoded UTM tuples written
79
88
  // by browser-side captureUtmsFromUrl(). First-touch records the
80
89
  // acquisition campaign (365-day TTL); last-touch records the
81
90
  // closer (90-day TTL, refreshed on each UTM-bearing visit).
82
- utmFirstRaw = cookieStore.get("_1c_utm_first")?.value
83
- utmLastRaw = cookieStore.get("_1c_utm_last")?.value
91
+ utmFirstRaw =
92
+ cookieStore.get(UTM_FIRST_COOKIE)?.value ??
93
+ cookieStore.get(LEGACY_UTM_FIRST_COOKIE)?.value
94
+ utmLastRaw =
95
+ cookieStore.get(UTM_LAST_COOKIE)?.value ??
96
+ cookieStore.get(LEGACY_UTM_LAST_COOKIE)?.value
84
97
 
85
98
  // _ga_<MEASUREMENT_ID> uses the GA4 measurementId (e.g., G-ABCDEF1234)
86
99
  // with the "G-" prefix stripped: cookie name = `_ga_ABCDEF1234`.
@@ -18,6 +18,10 @@
18
18
  * from its own config. This is the one a storefront should mount;
19
19
  * the individual tags below are for a layout that needs control.
20
20
  * - <TrackInit /> — captures UTMs and ad-click ids on the landing page
21
+ * - <PageViews /> — one pageview per navigation, client-side ones
22
+ * included; the platform derives page type + entity id from the path
23
+ * - <LiveHeartbeat /> — pings while a tab is open, so the merchant's live
24
+ * visitor count means "right now" rather than "recently active"
21
25
  * (they exist only there) and starts the engagement clock.
22
26
  * - <MetaPixel pixelId> / <GoogleTag measurementId adsConversionId> /
23
27
  * <TikTokPixel pixelId> / <Gtm containerId> / <Rybbit siteId> —
@@ -53,8 +57,16 @@ export { MetaPixel, updatePixelAdvancedMatching } from "./meta-pixel"
53
57
  export { GA4, GoogleTag } from "./ga4"
54
58
  export { Gtm } from "./gtm"
55
59
  export { TikTokPixel } from "./tiktok-pixel"
60
+ export { ChatGptPixel } from "./chatgpt-pixel"
56
61
  export { StorefrontTags } from "./storefront-tags"
57
62
  export { TrackInit } from "./track-init"
63
+ // The live-visitor heartbeat. Mounted by <StorefrontTags> so a storefront
64
+ // gets it without knowing it exists.
65
+ export { LiveHeartbeat } from "./live-heartbeat"
66
+ // Every navigation, automatically. Mounted by <StorefrontTags>; the page
67
+ // type and the record it was about are derived by the platform, never
68
+ // declared here.
69
+ export { PageViews } from "./page-views"
58
70
  export { TrackOrderPurchase } from "./track-order-purchase"
59
71
  export { Rybbit } from "./rybbit"
60
72
  export { ConsentInit } from "./consent-init"
@@ -123,6 +135,17 @@ export {
123
135
  tiktokPurchaseEventId,
124
136
  type TikTokContentItem,
125
137
  } from "./ttq"
138
+ export {
139
+ trackOpenAiPageView,
140
+ trackOpenAiContentsViewed,
141
+ trackOpenAiItemsAdded,
142
+ trackOpenAiCheckoutStarted,
143
+ trackOpenAiOrderCreated,
144
+ applyOpenAiConsent,
145
+ openAiOrderEventId,
146
+ minorUnits,
147
+ type OpenAiContentItem,
148
+ } from "./oaiq"
126
149
  export {
127
150
  trackGoogleAdsPurchase,
128
151
  googleAdsPurchaseSendTo,
@@ -0,0 +1,63 @@
1
+ "use client"
2
+
3
+ import { useEffect } from "react"
4
+
5
+ import { getTabSessionId, platformCollectorPresent, readVisitorId } from "../lib/visitor"
6
+
7
+ /**
8
+ * The live-visitor heartbeat.
9
+ *
10
+ * While this tab is open it pings the store's own domain every 15 seconds.
11
+ * The platform counts a visitor as present while their last ping is inside
12
+ * a 30 second window, so a closed tab, a killed browser, a dropped network
13
+ * or a locked phone all disappear within half a minute with nothing to
14
+ * detect them — no unload handler, which browsers do not guarantee anyway,
15
+ * especially on mobile.
16
+ *
17
+ * Why a heartbeat instead of counting recent events: a shopper reading one
18
+ * product page for four minutes sends nothing in between, so an
19
+ * event-window count either forgets them or keeps counting people who left.
20
+ * A ping answers "who is on the store right now" literally.
21
+ *
22
+ * The path is first-party (`/_cb/heartbeat` on the merchant's own domain),
23
+ * which is what keeps it out of ad blockers and away from third-party
24
+ * cookie rules. The platform's injected proxy forwards it.
25
+ *
26
+ * Costs nothing when it fails: every call is fire-and-forget with keepalive,
27
+ * a dropped ping is one missing dot on a chart.
28
+ */
29
+ const HEARTBEAT_MS = 15_000
30
+ const ENDPOINT = "/_cb/heartbeat"
31
+
32
+ export function LiveHeartbeat() {
33
+ useEffect(() => {
34
+ // The deploy-injected collector already does this on every page.
35
+ if (platformCollectorPresent()) return
36
+ const session = getTabSessionId()
37
+
38
+ const ping = () => {
39
+ const body = JSON.stringify({
40
+ session_id: session,
41
+ device_id: readVisitorId(),
42
+ // Path only. The platform drops query strings as well, because
43
+ // signed document links and login codes travel in them.
44
+ path: window.location.pathname,
45
+ })
46
+ void fetch(ENDPOINT, {
47
+ method: "POST",
48
+ headers: { "content-type": "application/json" },
49
+ body,
50
+ keepalive: true,
51
+ }).catch(() => undefined)
52
+ }
53
+
54
+ ping()
55
+ // No visibility handling on purpose (the live-polling law): browsers
56
+ // already throttle timers in a hidden tab, and anything that guesses at
57
+ // attention will eventually guess wrong.
58
+ const timer = setInterval(ping, HEARTBEAT_MS)
59
+ return () => clearInterval(timer)
60
+ }, [])
61
+
62
+ return null
63
+ }
@@ -0,0 +1,206 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Typed wrappers around the global `oaiq` command queue from the OpenAI
5
+ * measurement pixel — the ChatGPT Ads twin of ./ttq.ts and ./fbq.ts, and
6
+ * deliberately the same shape so a surface that fires one can fire the
7
+ * other on the line below.
8
+ *
9
+ * Every helper no-ops when `window.oaiq` is absent (pixel not configured,
10
+ * server render, script blocked), so callers need no guards.
11
+ *
12
+ * The call signature is theirs, from
13
+ * https://developers.openai.com/ads/measurement-pixel:
14
+ *
15
+ * oaiq("measure", eventName, eventData, { event_id })
16
+ *
17
+ * Two things here are NOT like the other vendors, and both are silent when
18
+ * wrong:
19
+ *
20
+ * 1. MONEY IS AN INTEGER IN MINOR UNITS. Meta, TikTok and GA4 all read
21
+ * `value: 25.99`; OpenAI reads `amount: 2599`. Passing a decimal
22
+ * under-reports revenue by a factor of a hundred with nothing to show
23
+ * for it, so every amount goes through `minorUnits` below and no
24
+ * caller passes `amount` itself.
25
+ * 2. EVENT DATA CARRIES ITS OWN `type`. Each event name is bound to one
26
+ * of four data shapes; the five commerce events we fire all take
27
+ * `contents`, so the wrappers set it and callers cannot get it wrong.
28
+ *
29
+ * Event names are theirs and are not interchangeable with Meta's or
30
+ * TikTok's: `contents_viewed`, `items_added`, `checkout_started`,
31
+ * `order_created`, `page_viewed`.
32
+ */
33
+
34
+ /** One product line inside `contents`. */
35
+ export type OpenAiContentItem = {
36
+ id: string
37
+ name?: string
38
+ content_type?: string
39
+ quantity?: number
40
+ /** Integer, minor units. */
41
+ amount?: number
42
+ currency?: string
43
+ }
44
+
45
+ type OpenAiEventData = {
46
+ type: "contents"
47
+ /** Integer, minor units. */
48
+ amount?: number
49
+ currency?: string
50
+ contents?: OpenAiContentItem[]
51
+ }
52
+
53
+ type OaiqFn = (
54
+ command: "init" | "measure" | "consent",
55
+ ...args: unknown[]
56
+ ) => void
57
+
58
+ function safeOaiq(): OaiqFn | null {
59
+ if (typeof window === "undefined") return null
60
+ const oaiq = (window as unknown as { oaiq?: OaiqFn }).oaiq
61
+ return typeof oaiq === "function" ? oaiq : null
62
+ }
63
+
64
+ /**
65
+ * Major units to their integer minor units: 25.99 becomes 2599.
66
+ *
67
+ * The twin of `openAiMinorUnits` in the platform's
68
+ * src/lib/tracking/constants.ts. Duplicated rather than imported because
69
+ * this package cannot reach into the platform, and rounded rather than
70
+ * truncated because 0.1 + 0.2 is 0.30000000000000004 in binary floating
71
+ * point and truncation would report 29 cents for 30.
72
+ */
73
+ export function minorUnits(amountInMajorUnits: number): number {
74
+ return Math.round((Number(amountInMajorUnits) || 0) * 100)
75
+ }
76
+
77
+ /**
78
+ * THE order dedup key — must equal what the backend forwarder sends
79
+ * (`openAiOrderEventId` in the platform's src/lib/tracking/constants.ts).
80
+ * Built from display_id here so the format cannot drift between the two
81
+ * sides, the same guarantee `trackPurchase` gives for Meta and
82
+ * `tiktokPurchaseEventId` for TikTok.
83
+ *
84
+ * OpenAI deduplicates on pixel id + event name + id, keeping the FIRST
85
+ * event it receives, so the browser and the server collapse into one
86
+ * conversion whichever arrives first.
87
+ */
88
+ export function openAiOrderEventId(displayId: string | number): string {
89
+ return `oai_order_${displayId}`
90
+ }
91
+
92
+ /** A page view. Their pixel fires nothing on its own at init. */
93
+ export function trackOpenAiPageView(): void {
94
+ const oaiq = safeOaiq()
95
+ if (!oaiq) return
96
+ oaiq("measure", "page_viewed", { type: "contents" } satisfies OpenAiEventData)
97
+ }
98
+
99
+ export function trackOpenAiContentsViewed(data: {
100
+ contentId: string
101
+ contentName?: string
102
+ currency: string
103
+ /** Major units — converted here. */
104
+ value: number
105
+ }): void {
106
+ const oaiq = safeOaiq()
107
+ if (!oaiq) return
108
+ oaiq("measure", "contents_viewed", {
109
+ type: "contents",
110
+ amount: minorUnits(data.value),
111
+ currency: data.currency,
112
+ contents: [
113
+ {
114
+ id: data.contentId,
115
+ name: data.contentName,
116
+ content_type: "product",
117
+ quantity: 1,
118
+ },
119
+ ],
120
+ } satisfies OpenAiEventData)
121
+ }
122
+
123
+ export function trackOpenAiItemsAdded(data: {
124
+ contentId: string
125
+ contentName?: string
126
+ quantity: number
127
+ /** Unit price, major units. */
128
+ price: number
129
+ currency: string
130
+ /** Line value, major units. */
131
+ value: number
132
+ }): void {
133
+ const oaiq = safeOaiq()
134
+ if (!oaiq) return
135
+ oaiq("measure", "items_added", {
136
+ type: "contents",
137
+ amount: minorUnits(data.value),
138
+ currency: data.currency,
139
+ contents: [
140
+ {
141
+ id: data.contentId,
142
+ name: data.contentName,
143
+ content_type: "product",
144
+ quantity: data.quantity,
145
+ amount: minorUnits(data.price),
146
+ currency: data.currency,
147
+ },
148
+ ],
149
+ } satisfies OpenAiEventData)
150
+ }
151
+
152
+ export function trackOpenAiCheckoutStarted(data: {
153
+ contents: OpenAiContentItem[]
154
+ currency: string
155
+ /** Cart value, major units. */
156
+ value: number
157
+ }): void {
158
+ const oaiq = safeOaiq()
159
+ if (!oaiq) return
160
+ oaiq("measure", "checkout_started", {
161
+ type: "contents",
162
+ amount: minorUnits(data.value),
163
+ currency: data.currency,
164
+ contents: data.contents,
165
+ } satisfies OpenAiEventData)
166
+ }
167
+
168
+ export function trackOpenAiOrderCreated(data: {
169
+ contents: OpenAiContentItem[]
170
+ currency: string
171
+ /** Order total, major units. */
172
+ value: number
173
+ /** The order's display id — the dedup key shared with the server. */
174
+ displayId: string | number
175
+ }): void {
176
+ const oaiq = safeOaiq()
177
+ if (!oaiq) return
178
+ oaiq(
179
+ "measure",
180
+ "order_created",
181
+ {
182
+ type: "contents",
183
+ amount: minorUnits(data.value),
184
+ currency: data.currency,
185
+ contents: data.contents,
186
+ } satisfies OpenAiEventData,
187
+ { event_id: openAiOrderEventId(data.displayId) }
188
+ )
189
+ }
190
+
191
+ /**
192
+ * Push a live consent decision to the pixel.
193
+ *
194
+ * Normally you do NOT call this: `applyConsent()` in ./consent.ts relays
195
+ * every decision to gtag, fbq, ttq and oaiq together. Exported for a host
196
+ * app driving the pixel from a consent layer of its own.
197
+ *
198
+ * Their own gate, from the measurement-pixel docs: with consent false the
199
+ * pixel sends no measurement pings, and the default is TRUE, which is why
200
+ * the loader closes it explicitly before init rather than trusting it.
201
+ */
202
+ export function applyOpenAiConsent(adsGranted: boolean): void {
203
+ const oaiq = safeOaiq()
204
+ if (!oaiq) return
205
+ oaiq("consent", adsGranted)
206
+ }
@@ -0,0 +1,96 @@
1
+ "use client"
2
+
3
+ import { Suspense, useEffect, useRef } from "react"
4
+ import { usePathname, useSearchParams } from "next/navigation"
5
+
6
+ import { getTabSessionId, platformCollectorPresent, readVisitorId } from "../lib/visitor"
7
+ import { readConsentCookie } from "./consent"
8
+
9
+ /**
10
+ * PAGEVIEWS — every route, automatically.
11
+ *
12
+ * Mounted once by <StorefrontTags>, this fires on every navigation the
13
+ * router performs, client-side ones included. That is the whole design:
14
+ * NOT a list of instrumented pages. Home, collections, blog posts, content
15
+ * pages, search, account and 404s are all captured without a single
16
+ * per-page call, which is the only shape that survives an agent inventing
17
+ * routes the platform has never seen.
18
+ *
19
+ * WHAT IT DELIBERATELY DOES NOT SEND: what the page IS. No `pageType` prop,
20
+ * no per-page declaration. The platform derives the page type and the id of
21
+ * the record the page was about from the path and its own catalog, because
22
+ * a declaration is a guarantee living in merchant code, and merchant code
23
+ * forgets. A route nobody anticipated still resolves, because the last path
24
+ * segment is looked up as a handle in the store's own catalog.
25
+ *
26
+ * The path is first-party (`/_cb/pageview` on the merchant's own domain),
27
+ * which keeps it out of ad blockers and away from third-party cookie rules.
28
+ *
29
+ * Query VALUES never leave the browser: only the keys travel, because a
30
+ * search term is shopper input and signed links live in query strings.
31
+ */
32
+ const ENDPOINT = "/_cb/pageview"
33
+
34
+ function PageViewsInner() {
35
+ const pathname = usePathname()
36
+ const searchParams = useSearchParams()
37
+ // The referrer for a client-side navigation is the page you came FROM,
38
+ // which the browser cannot tell us — document.referrer only ever holds
39
+ // the page that loaded the app.
40
+ const lastPath = useRef<string | null>(null)
41
+
42
+ const search = searchParams?.toString() ?? ""
43
+
44
+ useEffect(() => {
45
+ if (!pathname) return
46
+ // The deploy-injected collector already reports every navigation.
47
+ if (platformCollectorPresent()) return
48
+
49
+ const previous = lastPath.current
50
+ lastPath.current = pathname
51
+
52
+ const consent = readConsentCookie()
53
+ const referrer = previous
54
+ ? `${window.location.origin}${previous}`
55
+ : typeof document !== "undefined" && document.referrer
56
+ ? document.referrer
57
+ : undefined
58
+
59
+ const body = JSON.stringify({
60
+ path: pathname,
61
+ query_keys: search ? Array.from(new URLSearchParams(search).keys()).slice(0, 20) : undefined,
62
+ referrer,
63
+ title: typeof document !== "undefined" ? document.title : undefined,
64
+ session_id: getTabSessionId(),
65
+ device_id: readVisitorId(),
66
+ // undefined when the store runs no banner, which the platform reads
67
+ // as "nothing to honour" rather than as a refusal.
68
+ consent: consent ? consent.analytics : undefined,
69
+ })
70
+
71
+ void fetch(ENDPOINT, {
72
+ method: "POST",
73
+ headers: { "content-type": "application/json" },
74
+ body,
75
+ keepalive: true,
76
+ }).catch(() => undefined)
77
+ // `search` is in the deps on purpose: a filter or a search query change
78
+ // is a new pageview, and a merchant reading a search report needs it.
79
+ }, [pathname, search])
80
+
81
+ return null
82
+ }
83
+
84
+ /**
85
+ * The Suspense boundary is INSIDE the component, not left to the consumer.
86
+ * `useSearchParams` opts a page out of static rendering unless it sits
87
+ * under one, and a storefront must not be able to lose its own static
88
+ * pages by mounting analytics. Nobody can wire this wrong.
89
+ */
90
+ export function PageViews() {
91
+ return (
92
+ <Suspense fallback={null}>
93
+ <PageViewsInner />
94
+ </Suspense>
95
+ )
96
+ }