Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/components/ExternalArticle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ type FeaturedArticle = {
title?: string;
url?: string;
date?: Date;
toolSlug?: string;
} & Partial<OgObject>;

const ExternalArticle: React.FC<FeaturedArticle> = (props) => {
const { url, ogImage, ogDate } = props;
const { url, ogImage, ogDate, toolSlug } = props;

const title = props.title ?? props.ogTitle ?? props.twitterTitle;

Expand Down Expand Up @@ -53,7 +54,13 @@ const ExternalArticle: React.FC<FeaturedArticle> = (props) => {
</div>
<div className="group relative">
<h3 className="mt-3 text-lg leading-6 font-semibold text-gray-900 group-hover:text-gray-600">
<Link href={url} className="font-medium no-underline">
<Link
href={url}
className="font-medium no-underline"
toolSlug={toolSlug}
linkType="featured_article"
linkPlacementDescription="featured-article-card"
>
<span className="absolute inset-0" />
{title}
</Link>
Expand Down
28 changes: 25 additions & 3 deletions src/components/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,30 @@ const SITE_URL = import.meta.env.PROD
? 'https://openapi.tools'
: 'http://localhost';

type LinkType =
| 'website'
| 'repo'
| 'sponsor_banner'
| 'featured_article'
| 'sponsor_link'
| 'other';

/**
* The props for the Link component
* @param {Category} category - The category of the tool
* @param {string} linkPlacementDescription - The description of where the link is placed. This will be slugified and used as the utm_content parameter
* @param {boolean} isSponsored - Whether this is a sponsored link (adds rel="sponsored" for FTC/Google compliance)
* @param {string} toolSlug - The slug of the tool (for analytics)
* @param {string} toolName - The name of the tool (for analytics)
* @param {LinkType} linkType - The type of link (for analytics)
*/
type LinkProps = React.HTMLProps<HTMLAnchorElement> & {
category?: Category;
linkPlacementDescription?: string;
isSponsored?: boolean;
toolSlug?: string;
toolName?: string;
linkType?: LinkType;
};

const Link: React.FC<LinkProps> = ({
Expand All @@ -28,6 +42,9 @@ const Link: React.FC<LinkProps> = ({
category,
linkPlacementDescription,
isSponsored,
toolSlug,
toolName,
linkType = 'other',
...rest
}) => {
// Compute rel attribute for external links
Expand All @@ -52,14 +69,19 @@ const Link: React.FC<LinkProps> = ({
(e: React.MouseEvent<HTMLAnchorElement>) => {
const href = e.currentTarget.href;

// If the link is an outbound link, track it
// If the link is an outbound link, track it with full context
if (href.startsWith('http') && !href.startsWith(SITE_URL)) {
posthog.capture('outbound_link_click', {
href,
url: href,
tool_slug: toolSlug,
tool_name: toolName,
link_type: linkType,
is_sponsored: isSponsored ?? false,
placement: linkPlacementDescription ?? 'unknown',
});
}
},
[]
[toolSlug, toolName, linkType, isSponsored, linkPlacementDescription]
);

const updatedUrl = React.useMemo(() => {
Expand Down
27 changes: 27 additions & 0 deletions src/components/SponsorBanner.astro
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ if (!sponsor) {
id="sponsor-banner"
>
<a
id="sponsor-banner-link"
href={generateUrlWithUTM({
url: sponsor.ctaUrl,
linkPlacementDescription: 'rotating sponsor banner',
Expand All @@ -43,9 +44,35 @@ if (!sponsor) {
</div>
</a>
<a
id="become-sponsor-link"
class="flex items-center px-2 py-1 text-nowrap md:justify-end"
href="/sponsor"
>
<span class="text-white">Sponsor openapi.tools</span>
</a>
</section>

<script is:inline define:vars={{ sponsorName: sponsor.name }}>
document
.getElementById('sponsor-banner-link')
?.addEventListener('click', () => {
if (window.posthog) {
window.posthog.capture('sponsorship_cta_click', {
cta_type: 'banner_click',
source_page: window.location.pathname,
sponsor_name: sponsorName,
});
}
});

document
.getElementById('become-sponsor-link')
?.addEventListener('click', () => {
if (window.posthog) {
window.posthog.capture('sponsorship_cta_click', {
cta_type: 'become_sponsor_link',
source_page: window.location.pathname,
});
}
});
</script>
56 changes: 51 additions & 5 deletions src/components/filters/FilterableToolsList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { X } from 'lucide-react';
import posthog from 'posthog-js';

import type { ToolRowData } from '@/components/table/Columns';
import { DataTable } from '@/components/table/DataTable';
Expand Down Expand Up @@ -27,6 +28,51 @@ export function FilterableToolsList({ tools }: FilterableToolsListProps) {
const availableLanguages = useMemo(() => extractLanguages(tools), [tools]);
const availablePlatforms = useMemo(() => extractPlatforms(tools), [tools]);

// Determine filter type (language vs platform)
const getFilterType = useCallback(
(value: string): 'language' | 'platform' => {
return availablePlatforms.some((p) => p.value === value)
? 'platform'
: 'language';
},
[availablePlatforms]
);

// Track filter changes
const handleToggleLanguage = useCallback(
(value: string) => {
const isRemoving = selectedLanguages.includes(value);

// Calculate what the filtered count will be after this change
const newSelected = isRemoving
? selectedLanguages.filter((l) => l !== value)
: [...selectedLanguages, value];
const newFilteredTools = filterToolsByLanguages(tools, newSelected);

posthog.capture('filter_applied', {
filter_type: getFilterType(value),
filter_value: value,
action: isRemoving ? 'removed' : 'added',
total_results: newFilteredTools.length,
});

toggleLanguage(value);
},
[selectedLanguages, toggleLanguage, tools, getFilterType]
);

const handleClearFilters = useCallback(() => {
if (selectedLanguages.length > 0) {
posthog.capture('filter_applied', {
filter_type: 'language',
filter_value: 'all',
action: 'cleared',
total_results: tools.length,
});
}
clearFilters();
}, [selectedLanguages.length, clearFilters, tools.length]);

// Filter tools based on selected languages (includes both languages and platforms)
const filteredTools = useMemo(
() => filterToolsByLanguages(tools, selectedLanguages),
Expand Down Expand Up @@ -58,13 +104,13 @@ export function FilterableToolsList({ tools }: FilterableToolsListProps) {
title="Languages"
options={availableLanguages}
selectedValues={selectedLanguages}
onToggle={toggleLanguage}
onToggle={handleToggleLanguage}
/>
<FilterPopover
title="Platforms"
options={availablePlatforms}
selectedValues={selectedLanguages}
onToggle={toggleLanguage}
onToggle={handleToggleLanguage}
/>

{hasFilters && (
Expand All @@ -77,7 +123,7 @@ export function FilterableToolsList({ tools }: FilterableToolsListProps) {
key={value}
variant="secondary"
className="cursor-pointer bg-slate-200 text-slate-700 hover:bg-slate-300 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
onClick={() => toggleLanguage(value)}
onClick={() => handleToggleLanguage(value)}
>
{option?.label || value}
<X className="ml-1 h-3 w-3" />
Expand All @@ -88,7 +134,7 @@ export function FilterableToolsList({ tools }: FilterableToolsListProps) {
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
onClick={handleClearFilters}
className="h-8 px-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
>
Clear all
Expand Down
60 changes: 60 additions & 0 deletions src/layouts/PostHogLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,65 @@ import PostHog from '../components/PostHog.astro';
</head>
<body>
<slot />

{/* Scroll Depth Tracking */}
<script is:inline>
(function () {
const thresholds = [25, 50, 75, 100];
const firedThresholds = new Set();

function getScrollPercent() {
const scrollTop =
window.scrollY || document.documentElement.scrollTop;
const scrollHeight =
document.documentElement.scrollHeight -
document.documentElement.clientHeight;
if (scrollHeight === 0) return 100;
return Math.round((scrollTop / scrollHeight) * 100);
}

function getToolSlug() {
const match = window.location.pathname.match(/^\/tools\/([^/]+)/);
return match ? match[1] : undefined;
}

function trackScrollDepth() {
const percent = getScrollPercent();

for (const threshold of thresholds) {
if (percent >= threshold && !firedThresholds.has(threshold)) {
firedThresholds.add(threshold);

if (window.posthog) {
window.posthog.capture('scroll_depth', {
page_path: window.location.pathname,
max_depth_percent: threshold,
tool_slug: getToolSlug(),
});
}
}
}
}

// Throttle scroll events
let ticking = false;
window.addEventListener(
'scroll',
function () {
if (!ticking) {
window.requestAnimationFrame(function () {
trackScrollDepth();
ticking = false;
});
ticking = true;
}
},
{ passive: true }
);

// Check initial scroll position (for pages that load scrolled)
window.addEventListener('load', trackScrollDepth);
})();
</script>
</body>
</html>
10 changes: 10 additions & 0 deletions src/pages/sponsor/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -182,4 +182,14 @@ const uniqueSponsors = activeSponsors.filter((tool) => {
</p>
</div>
</Layout>

{/* Sponsor Page Visit Tracking */}
<script is:inline>
if (typeof window !== 'undefined' && window.posthog) {
window.posthog.capture('sponsorship_cta_click', {
cta_type: 'sponsor_page_visit',
source_page: document.referrer || 'direct',
});
}
</script>
</PostHogLayout>
32 changes: 31 additions & 1 deletion src/pages/tools/[...slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ const toolBadges = tool.data.badges
variant="primary"
isSponsored={isSponsorshipActive(tool.data)}
className="inline-flex items-center gap-2"
toolSlug={slug as string}
toolName={tool.data.name}
linkType="website"
linkPlacementDescription="tool-detail-page-header"
>
<WebsiteIcon className="h-4 w-4" />
Visit Website
Expand All @@ -118,6 +122,10 @@ const toolBadges = tool.data.badges
variant="secondary"
isSponsored={isSponsorshipActive(tool.data)}
className="inline-flex items-center gap-2"
toolSlug={slug as string}
toolName={tool.data.name}
linkType="repo"
linkPlacementDescription="tool-detail-page-header"
>
<RepoIcon className="h-4 w-4" repo={tool.data.repo} />
View Repository
Expand Down Expand Up @@ -232,12 +240,34 @@ const toolBadges = tool.data.badges

<section class="md-grid-cols-2 grid gap-4 lg:grid-cols-3">
{enrichedFeaturedArticles.map((article) => (
<ExternalArticle {...article.og} />
<ExternalArticle {...article.og} toolSlug={slug as string} />
))}
</section>
</>
)
}
</main>
</Layout>

{/* Tool Page View Tracking */}
<script
is:inline
define:vars={{
toolSlug: slug,
toolName: tool.data.name,
categoryNames: categories.map((c) => c?.data.name).filter(Boolean),
isSponsored: isSponsorshipActive(tool.data),
oasVersions: tool.data.oasVersions || {},
}}
>
if (typeof window !== 'undefined' && window.posthog) {
window.posthog.capture('tool_page_view', {
tool_slug: toolSlug,
tool_name: toolName,
categories: categoryNames,
is_sponsored: isSponsored,
oas_versions: oasVersions,
});
}
</script>
</PostHogLayout>
Loading