Add Reddit conversion tracking (#208)
This commit is contained in:
parent
aebc779b93
commit
4f638db702
23
drizzle/0017_omniscient_dagger.sql
Normal file
23
drizzle/0017_omniscient_dagger.sql
Normal file
@ -0,0 +1,23 @@
|
||||
CREATE TABLE `reddit_attributions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`click_id` text,
|
||||
`uuid` text,
|
||||
`landing_page` text,
|
||||
`referrer` text,
|
||||
`utm_source` text,
|
||||
`utm_medium` text,
|
||||
`utm_campaign` text,
|
||||
`utm_term` text,
|
||||
`utm_content` text,
|
||||
`signup_sent_at` text,
|
||||
`purchase_sent_at` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
`updated_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `reddit_attributions_user_idx` ON `reddit_attributions` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `reddit_attributions_organization_idx` ON `reddit_attributions` (`organization_id`);
|
||||
2333
drizzle/meta/0017_snapshot.json
Normal file
2333
drizzle/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -120,6 +120,13 @@
|
||||
"when": 1779138211145,
|
||||
"tag": "0016_magical_the_fallen",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "6",
|
||||
"when": 1779331224828,
|
||||
"tag": "0017_omniscient_dagger",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
83
src/client/lib/reddit-attribution.ts
Normal file
83
src/client/lib/reddit-attribution.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import type { RedditAttributionInput } from "@/shared/reddit-attribution";
|
||||
import { redditAttributionSchema } from "@/shared/reddit-attribution";
|
||||
|
||||
const STORAGE_KEY = "openseo:reddit-attribution";
|
||||
const SIGNUP_SENT_KEY = "openseo:reddit-signup-conversion-sent";
|
||||
|
||||
function readCookie(name: string) {
|
||||
const prefix = `${name}=`;
|
||||
return document.cookie
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith(prefix))
|
||||
?.slice(prefix.length);
|
||||
}
|
||||
|
||||
function firstSearchValue(searchParams: URLSearchParams, names: string[]) {
|
||||
for (const name of names) {
|
||||
const value = searchParams.get(name)?.trim();
|
||||
if (value) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function captureRedditAttributionFromLocation() {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const url = new URL(window.location.href);
|
||||
const current = getStoredRedditAttribution();
|
||||
const next = redditAttributionSchema.parse({
|
||||
clickId:
|
||||
firstSearchValue(url.searchParams, ["rdt_cid", "reddit_click_id"]) ??
|
||||
current?.clickId,
|
||||
uuid: readCookie("_rdt_uuid") ?? current?.uuid,
|
||||
landingPage: current?.landingPage ?? url.toString(),
|
||||
referrer: (current?.referrer ?? document.referrer) || undefined,
|
||||
utmSource:
|
||||
firstSearchValue(url.searchParams, ["utm_source"]) ?? current?.utmSource,
|
||||
utmMedium:
|
||||
firstSearchValue(url.searchParams, ["utm_medium"]) ?? current?.utmMedium,
|
||||
utmCampaign:
|
||||
firstSearchValue(url.searchParams, ["utm_campaign"]) ??
|
||||
current?.utmCampaign,
|
||||
utmTerm:
|
||||
firstSearchValue(url.searchParams, ["utm_term"]) ?? current?.utmTerm,
|
||||
utmContent:
|
||||
firstSearchValue(url.searchParams, ["utm_content"]) ??
|
||||
current?.utmContent,
|
||||
});
|
||||
|
||||
if (!next.clickId && next.utmSource?.toLowerCase() !== "reddit") return;
|
||||
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
export function getStoredRedditAttribution(): RedditAttributionInput | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
const result = redditAttributionSchema.safeParse(parsed);
|
||||
return result.success ? result.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasMarkedRedditSignupConversion(userId: string) {
|
||||
if (typeof window === "undefined") return true;
|
||||
return window.localStorage.getItem(SIGNUP_SENT_KEY) === userId;
|
||||
}
|
||||
|
||||
export function markRedditSignupConversion(userId: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.setItem(SIGNUP_SENT_KEY, userId);
|
||||
}
|
||||
|
||||
export function unmarkRedditSignupConversion() {
|
||||
if (typeof window === "undefined") return;
|
||||
window.localStorage.removeItem(SIGNUP_SENT_KEY);
|
||||
}
|
||||
37
src/db/reddit-attribution.schema.ts
Normal file
37
src/db/reddit-attribution.schema.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { index, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import { organization, user } from "./better-auth-schema";
|
||||
|
||||
export const redditAttributions = sqliteTable(
|
||||
"reddit_attributions",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: "cascade" }),
|
||||
organizationId: text("organization_id")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
clickId: text("click_id"),
|
||||
uuid: text("uuid"),
|
||||
landingPage: text("landing_page"),
|
||||
referrer: text("referrer"),
|
||||
utmSource: text("utm_source"),
|
||||
utmMedium: text("utm_medium"),
|
||||
utmCampaign: text("utm_campaign"),
|
||||
utmTerm: text("utm_term"),
|
||||
utmContent: text("utm_content"),
|
||||
signupSentAt: text("signup_sent_at"),
|
||||
purchaseSentAt: text("purchase_sent_at"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("reddit_attributions_user_idx").on(table.userId),
|
||||
index("reddit_attributions_organization_idx").on(table.organizationId),
|
||||
],
|
||||
);
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./app.schema";
|
||||
export * from "./better-auth-schema";
|
||||
export * from "./reddit-attribution.schema";
|
||||
|
||||
@ -19,6 +19,13 @@ import {
|
||||
startAnalyticsCapture,
|
||||
stopAnalyticsCapture,
|
||||
} from "@/client/lib/posthog";
|
||||
import {
|
||||
captureRedditAttributionFromLocation,
|
||||
getStoredRedditAttribution,
|
||||
hasMarkedRedditSignupConversion,
|
||||
markRedditSignupConversion,
|
||||
unmarkRedditSignupConversion,
|
||||
} from "@/client/lib/reddit-attribution";
|
||||
import { NotFound } from "@/client/components/NotFound";
|
||||
import appCss from "@/client/styles/app.css?url";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
@ -26,6 +33,7 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
import { Toaster } from "sonner";
|
||||
import { queryClient } from "@/client/tanstack-db";
|
||||
import { getActiveOrganizationId } from "@/lib/auth-session";
|
||||
import { captureRedditConversionEvent } from "@/serverFunctions/redditConversions";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
head: () => ({
|
||||
@ -90,6 +98,11 @@ function PostHogBootstrap() {
|
||||
const optedOut = session?.user?.analyticsOptedOut === true;
|
||||
const organizationId = getActiveOrganizationId(session);
|
||||
const previousUserIdRef = React.useRef<string | null>(null);
|
||||
const redditSignupInFlightRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
captureRedditAttributionFromLocation();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isHostedMode || isSessionPending) {
|
||||
@ -108,6 +121,32 @@ function PostHogBootstrap() {
|
||||
}
|
||||
}, [isHostedMode, isSessionPending, optedOut, organizationId, userId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isHostedMode || isSessionPending || !userId) return;
|
||||
if (hasMarkedRedditSignupConversion(userId)) return;
|
||||
|
||||
const attribution = getStoredRedditAttribution();
|
||||
if (!attribution) return;
|
||||
if (redditSignupInFlightRef.current) return;
|
||||
|
||||
redditSignupInFlightRef.current = true;
|
||||
void captureRedditConversionEvent({
|
||||
data: { attribution, eventType: "SignUp" },
|
||||
})
|
||||
.then((result) => {
|
||||
if (result.status === "sent" || result.status === "already_sent") {
|
||||
markRedditSignupConversion(userId);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// The server deduplicates this event; allow a future session to retry.
|
||||
unmarkRedditSignupConversion();
|
||||
})
|
||||
.finally(() => {
|
||||
redditSignupInFlightRef.current = false;
|
||||
});
|
||||
}, [isHostedMode, isSessionPending, userId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { createFileRoute, notFound } from "@tanstack/react-router";
|
||||
import { AutumnProvider, useCustomer } from "autumn-js/react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "@/lib/auth-client";
|
||||
import { isHostedClientAuthMode } from "@/lib/auth-mode";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { getStoredRedditAttribution } from "@/client/lib/reddit-attribution";
|
||||
import { BillingUsageChart } from "@/client/features/billing/BillingUsageChart";
|
||||
import { parseTopUpAmount } from "@/client/features/billing/HostedBillingContentUtils";
|
||||
import { getBillingRouteState } from "@/client/features/billing/route-state";
|
||||
@ -17,6 +18,7 @@ import {
|
||||
AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID,
|
||||
autumnSeoDataCreditsToUsd,
|
||||
} from "@/shared/billing";
|
||||
import { captureRedditConversionEvent } from "@/serverFunctions/redditConversions";
|
||||
|
||||
export const Route = createFileRoute("/_app/billing")({
|
||||
beforeLoad: () => {
|
||||
@ -68,6 +70,20 @@ function BillingPageContent() {
|
||||
|
||||
const { isValid: isValidTopUp, parsed: parsedTopUpAmount } =
|
||||
parseTopUpAmount(topUpAmount);
|
||||
const checkoutCompleted =
|
||||
typeof window !== "undefined" &&
|
||||
new URLSearchParams(window.location.search).get("checkout") === "success";
|
||||
|
||||
useEffect(() => {
|
||||
if (!checkoutCompleted || billingRouteState !== "ready") return;
|
||||
|
||||
const attribution = getStoredRedditAttribution();
|
||||
if (!attribution) return;
|
||||
|
||||
void captureRedditConversionEvent({
|
||||
data: { attribution, eventType: "Purchase" },
|
||||
});
|
||||
}, [billingRouteState, checkoutCompleted]);
|
||||
|
||||
if (billingRouteState === "loading") {
|
||||
return null;
|
||||
@ -202,7 +218,7 @@ function BillingPageContent() {
|
||||
customerQuery.attach({
|
||||
planId: AUTUMN_PAID_PLAN_ID,
|
||||
redirectMode: "always",
|
||||
successUrl: window.location.href,
|
||||
successUrl: `${window.location.origin}${window.location.pathname}?checkout=success`,
|
||||
}),
|
||||
"We couldn't start the checkout. Please try again.",
|
||||
)
|
||||
|
||||
@ -4,11 +4,13 @@ import { useEffect, useState } from "react";
|
||||
import { ArrowRight, Settings, User } from "lucide-react";
|
||||
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
|
||||
import { captureClientEvent } from "@/client/lib/posthog";
|
||||
import { getStoredRedditAttribution } from "@/client/lib/reddit-attribution";
|
||||
import { signOutAndRedirect, useSession } from "@/lib/auth-client";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
import { getSubscribeRouteState } from "@/client/features/billing/route-state";
|
||||
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
|
||||
import { AUTUMN_PAID_PLAN_ID } from "@/shared/billing";
|
||||
import { captureRedditConversionEvent } from "@/serverFunctions/redditConversions";
|
||||
|
||||
export const Route = createFileRoute("/_authenticated/subscribe")({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
@ -54,6 +56,15 @@ function SubscribePageContent() {
|
||||
if (subscribeRouteState === "redirectToApp") {
|
||||
if (checkoutCompleted) {
|
||||
captureClientEvent("billing:checkout_success");
|
||||
const attribution = getStoredRedditAttribution();
|
||||
if (attribution) {
|
||||
void captureRedditConversionEvent({
|
||||
data: { attribution, eventType: "Purchase" },
|
||||
}).finally(() => {
|
||||
void navigate({ to: "/", replace: true });
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
void navigate({ to: "/", replace: true });
|
||||
}
|
||||
|
||||
172
src/server/lib/reddit-conversions.ts
Normal file
172
src/server/lib/reddit-conversions.ts
Normal file
@ -0,0 +1,172 @@
|
||||
import { env } from "cloudflare:workers";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import { redditAttributions } from "@/db/schema";
|
||||
import {
|
||||
hasRedditAttribution,
|
||||
type RedditAttributionInput,
|
||||
} from "@/shared/reddit-attribution";
|
||||
|
||||
type RedditConversionType = "SignUp" | "Purchase";
|
||||
|
||||
type CaptureRedditConversionArgs = {
|
||||
attribution: RedditAttributionInput;
|
||||
conversionId: string;
|
||||
email: string;
|
||||
eventType: RedditConversionType;
|
||||
organizationId: string;
|
||||
userId: string;
|
||||
valueDecimal?: number;
|
||||
currency?: string;
|
||||
};
|
||||
|
||||
function getEnv(name: string) {
|
||||
const value: unknown = Reflect.get(env, name);
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
async function sha256(value: string) {
|
||||
const bytes = new TextEncoder().encode(value.trim().toLowerCase());
|
||||
const hash = await crypto.subtle.digest("SHA-256", bytes);
|
||||
return [...new Uint8Array(hash)]
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function getRedditConfig() {
|
||||
const accountId = getEnv("REDDIT_AD_ACCOUNT_ID") || getEnv("REDDIT_PIXEL_ID");
|
||||
const accessToken = getEnv("REDDIT_CONVERSIONS_ACCESS_TOKEN");
|
||||
|
||||
if (!accountId || !accessToken) return null;
|
||||
|
||||
return { accountId, accessToken };
|
||||
}
|
||||
|
||||
async function upsertAttribution(args: CaptureRedditConversionArgs) {
|
||||
const existing = await db.query.redditAttributions.findFirst({
|
||||
where: eq(redditAttributions.userId, args.userId),
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (existing) {
|
||||
await db
|
||||
.update(redditAttributions)
|
||||
.set({
|
||||
clickId: existing.clickId ?? args.attribution.clickId,
|
||||
uuid: existing.uuid ?? args.attribution.uuid,
|
||||
landingPage: existing.landingPage ?? args.attribution.landingPage,
|
||||
referrer: existing.referrer ?? args.attribution.referrer,
|
||||
utmSource: existing.utmSource ?? args.attribution.utmSource,
|
||||
utmMedium: existing.utmMedium ?? args.attribution.utmMedium,
|
||||
utmCampaign: existing.utmCampaign ?? args.attribution.utmCampaign,
|
||||
utmTerm: existing.utmTerm ?? args.attribution.utmTerm,
|
||||
utmContent: existing.utmContent ?? args.attribution.utmContent,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(redditAttributions.userId, args.userId));
|
||||
} else {
|
||||
await db.insert(redditAttributions).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId: args.userId,
|
||||
organizationId: args.organizationId,
|
||||
clickId: args.attribution.clickId,
|
||||
uuid: args.attribution.uuid,
|
||||
landingPage: args.attribution.landingPage,
|
||||
referrer: args.attribution.referrer,
|
||||
utmSource: args.attribution.utmSource,
|
||||
utmMedium: args.attribution.utmMedium,
|
||||
utmCampaign: args.attribution.utmCampaign,
|
||||
utmTerm: args.attribution.utmTerm,
|
||||
utmContent: args.attribution.utmContent,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function hasSentConversion(args: CaptureRedditConversionArgs) {
|
||||
const existing = await db.query.redditAttributions.findFirst({
|
||||
where: eq(redditAttributions.userId, args.userId),
|
||||
});
|
||||
return args.eventType === "SignUp"
|
||||
? Boolean(existing?.signupSentAt)
|
||||
: Boolean(existing?.purchaseSentAt);
|
||||
}
|
||||
|
||||
async function markConversionSent(args: CaptureRedditConversionArgs) {
|
||||
const now = new Date().toISOString();
|
||||
const sentColumn =
|
||||
args.eventType === "SignUp" ? "signupSentAt" : "purchaseSentAt";
|
||||
|
||||
await db
|
||||
.update(redditAttributions)
|
||||
.set({
|
||||
[sentColumn]: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(redditAttributions.userId, args.userId));
|
||||
}
|
||||
|
||||
export async function captureRedditConversion(
|
||||
args: CaptureRedditConversionArgs,
|
||||
) {
|
||||
if (!hasRedditAttribution(args.attribution)) return "skipped" as const;
|
||||
|
||||
await upsertAttribution(args);
|
||||
if (await hasSentConversion(args)) return "already_sent" as const;
|
||||
|
||||
const config = getRedditConfig();
|
||||
if (!config) return "stored" as const;
|
||||
|
||||
const eventMetadata: Record<string, unknown> = {
|
||||
conversion_id: args.conversionId,
|
||||
transaction_id: args.conversionId,
|
||||
};
|
||||
if (args.valueDecimal !== undefined) {
|
||||
eventMetadata.value_decimal = args.valueDecimal;
|
||||
eventMetadata.currency = args.currency ?? "USD";
|
||||
eventMetadata.item_count = 1;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
events: [
|
||||
{
|
||||
click_id: args.attribution.clickId,
|
||||
event_at: new Date().toISOString(),
|
||||
event_type: {
|
||||
tracking_type: args.eventType,
|
||||
},
|
||||
event_metadata: eventMetadata,
|
||||
user: {
|
||||
email: await sha256(args.email),
|
||||
external_id: await sha256(args.userId),
|
||||
uuid: args.attribution.uuid,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`https://ads-api.reddit.com/api/v2.0/conversions/events/${config.accountId}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("reddit conversion capture failed", {
|
||||
status: response.status,
|
||||
eventType: args.eventType,
|
||||
userId: args.userId,
|
||||
});
|
||||
return "failed" as const;
|
||||
}
|
||||
|
||||
await markConversionSent(args);
|
||||
return "sent" as const;
|
||||
}
|
||||
28
src/serverFunctions/redditConversions.ts
Normal file
28
src/serverFunctions/redditConversions.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { z } from "zod";
|
||||
import { captureRedditConversion } from "@/server/lib/reddit-conversions";
|
||||
import { redditAttributionSchema } from "@/shared/reddit-attribution";
|
||||
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||
|
||||
const conversionInputSchema = z.object({
|
||||
attribution: redditAttributionSchema,
|
||||
eventType: z.enum(["SignUp", "Purchase"]),
|
||||
});
|
||||
|
||||
export const captureRedditConversionEvent = createServerFn({ method: "POST" })
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.inputValidator((data: unknown) => conversionInputSchema.parse(data))
|
||||
.handler(async ({ data, context }) => {
|
||||
const status = await captureRedditConversion({
|
||||
attribution: data.attribution,
|
||||
conversionId: `${data.eventType.toLowerCase()}:${context.userId}`,
|
||||
email: context.userEmail,
|
||||
eventType: data.eventType,
|
||||
organizationId: context.organizationId,
|
||||
userId: context.userId,
|
||||
valueDecimal: data.eventType === "Purchase" ? 10 : undefined,
|
||||
currency: data.eventType === "Purchase" ? "USD" : undefined,
|
||||
});
|
||||
|
||||
return { status };
|
||||
});
|
||||
24
src/shared/reddit-attribution.ts
Normal file
24
src/shared/reddit-attribution.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const redditAttributionSchema = z.object({
|
||||
clickId: z.string().trim().min(1).max(256).optional(),
|
||||
uuid: z.string().trim().min(1).max(256).optional(),
|
||||
landingPage: z.string().trim().min(1).max(2048).optional(),
|
||||
referrer: z.string().trim().max(2048).optional(),
|
||||
utmSource: z.string().trim().min(1).max(256).optional(),
|
||||
utmMedium: z.string().trim().min(1).max(256).optional(),
|
||||
utmCampaign: z.string().trim().min(1).max(256).optional(),
|
||||
utmTerm: z.string().trim().min(1).max(256).optional(),
|
||||
utmContent: z.string().trim().min(1).max(256).optional(),
|
||||
});
|
||||
|
||||
export type RedditAttributionInput = z.infer<typeof redditAttributionSchema>;
|
||||
|
||||
export function hasRedditAttribution(input: RedditAttributionInput) {
|
||||
return Boolean(
|
||||
input.clickId ||
|
||||
input.uuid ||
|
||||
input.utmSource?.toLowerCase() === "reddit" ||
|
||||
input.referrer?.toLowerCase().includes("reddit."),
|
||||
);
|
||||
}
|
||||
@ -33,7 +33,7 @@ The Site and the Service are not directed to anyone under the age of 13\. As sta
|
||||
|
||||
II. HOW WE USE AND SHARE INFORMATION
|
||||
_Personal Information:_
|
||||
Except as otherwise stated in this Privacy Policy, we do not sell, trade, rent or otherwise share for marketing purposes your Personal Information with third parties without your consent. We do share Personal Information with vendors who are performing services for the Company, such as providers that help us send email communications, operate analytics, process billing, provide data requested through the Service, and host or support the infrastructure behind the Site and Service. These vendors may include Loops for email communications, Plausible for public-site analytics, PostHog for hosted analytics, session replay, and error monitoring, Autumn and Stripe for hosted billing and payment processing, Cloudflare for hosting, storage, and access controls, and DataForSEO for data requested through the Service. Those vendors use your Personal Information only at our direction and in accordance with our Privacy Policy.
|
||||
Except as otherwise stated in this Privacy Policy, we do not sell, trade, rent or otherwise share for marketing purposes your Personal Information with third parties without your consent. We do share Personal Information with vendors who are performing services for the Company, such as providers that help us send email communications, operate analytics, measure advertising conversions, process billing, provide data requested through the Service, and host or support the infrastructure behind the Site and Service. These vendors may include Loops for email communications, Plausible for public-site analytics, PostHog for hosted analytics, session replay, and error monitoring, Reddit Ads for advertising conversion measurement, Autumn and Stripe for hosted billing and payment processing, Cloudflare for hosting, storage, and access controls, and DataForSEO for data requested through the Service. Those vendors use your Personal Information only at our direction and in accordance with our Privacy Policy.
|
||||
In general, the Personal Information you provide to us is used to help us communicate with you and operate the Service. For example, we use Personal Information to create and secure accounts, authenticate users, provide technical support, process billing, send administrative or transactional emails, contact users in response to questions, solicit feedback from users, and inform users about product updates and promotional offers.
|
||||
We may share Personal Information with outside parties if we have a good-faith belief that access, use, preservation or disclosure of the information is reasonably necessary to meet any applicable legal process or enforceable governmental request; to enforce applicable Terms of Service, including investigation of potential violations; address fraud, security or technical concerns; or to protect against harm to the rights, property, or safety of our users or the public as required or permitted by law.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user