Add Analytics package (#46)

* Add Analytics package

Created a new analytics package with a manager to handle tracking of events and page views. The package includes a default provider that can be switched out and uses a NullAnalyticsService if no provider is registered. Additional types, scripts, and package configuration are also provided to support development.

* Add marketing components for UI package

Introduced new React components under "marketing" for the UI package. These include 'Pill', 'GradientSecondaryText', 'Hero', 'CtaButton', 'FeatureCard', 'FeatureGrid', 'FeatureShowcase', 'GradientText', 'Header', and 'SecondaryHero'. Updated 'package.json' to export these components. Replaced the implementation of 'Home', 'SiteHeader', and 'SiteFooter' with these components for cleaner code and better reusability.
This commit is contained in:
Giancarlo Buomprisco
2024-07-19 23:33:52 +08:00
committed by GitHub
parent 5ee7bacb2a
commit 86d82d889c
28 changed files with 862 additions and 417 deletions

View File

@@ -0,0 +1,10 @@
# Analytics - @kit/analytics
@kit/analytics Package provides a simple and consistent API for tracking analytics events in web applications.
## Overview
This version of the `@kit/analytics` package uses classes internally for structure and encapsulation, but exposes a functional API for ease of use and consistency with Makerkit's style.
## Implementation

View File

@@ -0,0 +1,35 @@
{
"name": "@kit/analytics",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*"
},
"eslintConfig": {
"root": true,
"extends": [
"@kit/eslint-config/base",
"@kit/eslint-config/react"
]
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -0,0 +1,72 @@
import { NullAnalyticsService } from './null-analytics-service';
import {
AnalyticsManager,
AnalyticsService,
CreateAnalyticsManagerOptions,
} from './types';
/**
* Creates an analytics manager that can be used to track page views and events. The manager is initialized with a
* default provider and can be switched to a different provider at any time. The manager will use a NullAnalyticsService
* if the provider is not registered.
* @param options
*/
export function createAnalyticsManager<T extends string, Config extends object>(
options: CreateAnalyticsManagerOptions<T, Config>,
): AnalyticsManager {
let activeService: AnalyticsService = NullAnalyticsService;
const getActiveService = (): AnalyticsService => {
if (activeService === NullAnalyticsService) {
console.warn(
'Analytics service not initialized. Using NullAnalyticsService.',
);
}
return activeService;
};
const initialize = (provider: T, config: Config) => {
const factory = options.providers[provider];
if (!factory) {
console.error(
`Analytics provider '${provider}' not registered. Using NullAnalyticsService.`,
);
activeService = NullAnalyticsService;
return;
}
activeService = factory(config);
activeService.initialize();
};
// Initialize with the default provider
initialize(options.defaultProvider, {} as Config);
return {
identify: (userId: string, traits?: Record<string, string>) => {
return getActiveService().identify(userId, traits);
},
/**
* Track a page view with the given URL.
* @param url
*/
trackPageView: (url: string) => {
return getActiveService().trackPageView(url);
},
/**
* Track an event with the given name and properties.
* @param eventName
* @param eventProperties
*/
trackEvent: (
eventName: string,
eventProperties?: Record<string, string | string[]>,
) => {
return getActiveService().trackEvent(eventName, eventProperties);
},
};
}

View File

@@ -0,0 +1,10 @@
import { createAnalyticsManager } from './analytics-manager';
import { NullAnalyticsService } from './null-analytics-service';
import type { AnalyticsManager } from './types';
export const analytics: AnalyticsManager = createAnalyticsManager({
defaultProvider: 'null',
providers: {
null: () => NullAnalyticsService,
},
});

View File

@@ -0,0 +1,16 @@
import { AnalyticsService } from './types';
const noop = () => {
// do nothing - this is to prevent errors when the analytics service is not initialized
};
/**
* Null analytics service that does nothing. It is initialized with a noop function. This is useful for testing or when
* the user is calling analytics methods before the analytics service is initialized.
*/
export const NullAnalyticsService: AnalyticsService = {
initialize: noop,
trackPageView: noop,
trackEvent: noop,
identify: noop,
};

View File

@@ -0,0 +1,32 @@
interface TrackEvent {
trackEvent(
eventName: string,
eventProperties?: Record<string, string | string[]>,
): void;
}
interface TrackPageView {
trackPageView(url: string): void;
}
interface Identify {
identify(userId: string, traits?: Record<string, string>): void;
}
export interface AnalyticsService extends TrackPageView, TrackEvent, Identify {
initialize(): void;
}
export type AnalyticsProviderFactory<Config extends object> = (
config: Config,
) => AnalyticsService;
export interface CreateAnalyticsManagerOptions<
T extends string,
Config extends object,
> {
defaultProvider: T;
providers: Record<T, AnalyticsProviderFactory<Config>>;
}
export interface AnalyticsManager extends TrackPageView, TrackEvent, Identify {}

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -189,7 +189,7 @@ function PricingItem(
<div className={'flex items-center space-x-6'}>
<b
className={
'text-current-foreground font-heading tracking-tight font-semibold uppercase'
'text-current-foreground font-heading font-semibold uppercase tracking-tight'
}
>
<Trans
@@ -341,7 +341,7 @@ function Price({ children }: React.PropsWithChildren) {
>
<span
className={
'font-heading flex items-center text-3xl font-bold lg:text-4xl tracking-tighter'
'font-heading flex items-center text-3xl font-bold tracking-tighter lg:text-4xl'
}
>
{children}

View File

@@ -121,7 +121,8 @@
"./cookie-banner": "./src/makerkit/cookie-banner.tsx",
"./card-button": "./src/makerkit/card-button.tsx",
"./version-updater": "./src/makerkit/version-updater.tsx",
"./multi-step-form": "./src/makerkit/multi-step-form.tsx"
"./multi-step-form": "./src/makerkit/multi-step-form.tsx",
"./marketing": "./src/makerkit/marketing/index.tsx"
},
"typesVersions": {
"*": {

View File

@@ -0,0 +1,23 @@
import { forwardRef } from 'react';
import { Button } from '../../shadcn/button';
import { cn } from '../../utils';
export const CtaButton = forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(function CtaButtonComponent({ className, children, ...props }, ref) {
return (
<Button
className={cn(
'h-12 rounded-xl px-4 text-base font-semibold transition-all hover:shadow-2xl dark:shadow-primary/30',
className,
)}
asChild
ref={ref}
{...props}
>
{children}
</Button>
);
});

View File

@@ -0,0 +1,44 @@
import React, { forwardRef } from 'react';
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '../../shadcn/card';
import { cn } from '../../utils';
interface FeatureCardProps extends React.HTMLAttributes<HTMLDivElement> {
label: string;
description: string;
image?: React.ReactNode;
}
export const FeatureCard = forwardRef<HTMLDivElement, FeatureCardProps>(
function FeatureCardComponent(
{ className, label, description, image, children, ...props },
ref,
) {
return (
<div
ref={ref}
className={cn(
'rounded-3xl p-2 ring-2 ring-gray-100 dark:ring-primary/10',
className,
)}
{...props}
>
<CardHeader>
<CardTitle className="text-xl font-semibold">{label}</CardTitle>
<CardDescription className="max-w-xs text-sm font-semibold tracking-tight text-current">
{description}
</CardDescription>
</CardHeader>
<CardContent>
{image}
{children}
</CardContent>
</div>
);
},
);

View File

@@ -0,0 +1,21 @@
import React, { forwardRef } from 'react';
import { cn } from '../../utils';
export const FeatureGrid = forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(function FeatureGridComponent({ className, children, ...props }, ref) {
return (
<div
ref={ref}
className={cn(
'grid w-full grid-cols-1 gap-6 space-y-0 lg:grid-cols-3',
className,
)}
{...props}
>
{children}
</div>
);
});

View File

@@ -0,0 +1,50 @@
import React, { forwardRef } from 'react';
import { cn } from '../../utils';
interface FeatureShowcaseProps extends React.HTMLAttributes<HTMLDivElement> {
heading: React.ReactNode;
icon?: React.ReactNode;
}
export const FeatureShowcase = forwardRef<HTMLDivElement, FeatureShowcaseProps>(
function FeatureShowcaseComponent(
{ className, heading, icon, children, ...props },
ref,
) {
return (
<div
ref={ref}
className={cn('flex flex-col justify-between space-y-8', className)}
{...props}
>
<div className="flex w-full max-w-5xl flex-col space-y-4">
{icon && <div className="flex">{icon}</div>}
<h3 className="text-3xl font-normal tracking-tighter xl:text-5xl">
{heading}
</h3>
</div>
{children}
</div>
);
},
);
export function FeatureShowcaseIconContainer(
props: React.PropsWithChildren<{
className?: string;
}>,
) {
return (
<div className={'flex'}>
<div
className={cn(
'flex items-center justify-center space-x-4 rounded-lg p-3 font-semibold',
props.className,
)}
>
{props.children}
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
import { forwardRef } from 'react';
import { cn } from '../../utils';
interface FooterSection {
heading: React.ReactNode;
links: Array<{
href: string;
label: React.ReactNode;
}>;
}
interface FooterProps extends React.HTMLAttributes<HTMLElement> {
logo: React.ReactNode;
description: React.ReactNode;
copyright: React.ReactNode;
sections: FooterSection[];
}
export const Footer = forwardRef<HTMLElement, FooterProps>(
function MarketingFooterComponent(
{ className, logo, description, copyright, sections, ...props },
ref,
) {
return (
<footer
ref={ref}
className={cn(
'site-footer relative mt-auto w-full py-8 2xl:py-16',
className,
)}
{...props}
>
<div className="container">
<div className="flex flex-col space-y-8 lg:flex-row lg:space-y-0">
<div className="flex w-full space-x-2 lg:w-4/12 xl:w-4/12 xl:space-x-6 2xl:space-x-8">
<div className="flex flex-col space-y-4">
<div>{logo}</div>
<div className="flex flex-col space-y-4">
<div>
<p className="text-sm text-muted-foreground">
{description}
</p>
</div>
<div className="flex text-xs text-muted-foreground">
<p>{copyright}</p>
</div>
</div>
</div>
</div>
<div className="flex w-full flex-col space-y-8 lg:flex-row lg:justify-end lg:space-x-6 lg:space-y-0 xl:space-x-16">
{sections.map((section, index) => (
<div key={index}>
<div className="flex flex-col space-y-2.5">
<FooterSectionHeading>
{section.heading}
</FooterSectionHeading>
<FooterSectionList>
{section.links.map((link, linkIndex) => (
<FooterLink key={linkIndex} href={link.href}>
{link.label}
</FooterLink>
))}
</FooterSectionList>
</div>
</div>
))}
</div>
</div>
</div>
</footer>
);
},
);
function FooterSectionHeading(props: React.PropsWithChildren) {
return <span className="font-heading">{props.children}</span>;
}
function FooterSectionList(props: React.PropsWithChildren) {
return <ul className="flex flex-col space-y-2.5">{props.children}</ul>;
}
function FooterLink({
href,
children,
}: React.PropsWithChildren<{ href: string }>) {
return (
<li className="text-sm text-muted-foreground hover:underline [&>a]:transition-colors">
<a href={href}>{children}</a>
</li>
);
}

View File

@@ -0,0 +1,27 @@
import { forwardRef } from 'react';
import { Slot, Slottable } from '@radix-ui/react-slot';
import { cn } from '../../utils';
export const GradientSecondaryText = forwardRef<
HTMLSpanElement,
React.HTMLAttributes<HTMLSpanElement> & {
asChild?: boolean;
}
>(function GradientSecondaryTextComponent({ className, ...props }, ref) {
const Comp = props.asChild ? Slot : 'span';
return (
<Comp
ref={ref}
className={cn(
'bg-gradient-to-r from-foreground/50 to-foreground bg-clip-text text-transparent',
className,
)}
{...props}
>
<Slottable>{props.children}</Slottable>
</Comp>
);
});

View File

@@ -0,0 +1,21 @@
import React, { forwardRef } from 'react';
import { cn } from '../../utils';
export const GradientText = forwardRef<
HTMLSpanElement,
React.HTMLAttributes<HTMLSpanElement>
>(function GradientTextComponent({ className, children, ...props }, ref) {
return (
<span
ref={ref}
className={cn(
'bg-gradient-to-r bg-clip-text text-transparent',
className,
)}
{...props}
>
{children}
</span>
);
});

View File

@@ -0,0 +1,37 @@
import { forwardRef } from 'react';
import { cn } from '../../utils';
interface HeaderProps extends React.HTMLAttributes<HTMLDivElement> {
logo?: React.ReactNode;
navigation?: React.ReactNode;
actions?: React.ReactNode;
}
export const Header = forwardRef<HTMLDivElement, HeaderProps>(
function MarketingHeaderComponent(
{ className, logo, navigation, actions, ...props },
ref,
) {
return (
<div
ref={ref}
className={cn(
'site-header sticky top-0 z-10 w-full bg-background/80 py-2 backdrop-blur-md dark:bg-background/50',
className,
)}
{...props}
>
<div className="container">
<div className="grid h-14 grid-cols-3 items-center">
<div>{logo}</div>
<div className="order-first md:order-none">{navigation}</div>
<div className="flex items-center justify-end space-x-1">
{actions}
</div>
</div>
</div>
</div>
);
},
);

View File

@@ -0,0 +1,27 @@
import { forwardRef } from 'react';
import { Slot, Slottable } from '@radix-ui/react-slot';
import { cn } from '../../utils';
export const HeroTitle = forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement> & {
asChild?: boolean;
}
>(function HeroTitleComponent({ children, className, ...props }, ref) {
const Comp = props.asChild ? Slot : 'h1';
return (
<Comp
ref={ref}
className={cn(
'hero-title flex flex-col space-y-1 text-center font-sans text-4xl font-semibold tracking-tighter dark:text-white sm:text-6xl lg:max-w-5xl lg:text-7xl xl:text-[5.125rem]',
className,
)}
{...props}
>
<Slottable>{children}</Slottable>
</Comp>
);
});

View File

@@ -0,0 +1,89 @@
import React from 'react';
import { Heading } from '../../shadcn/heading';
import { cn } from '../../utils';
import { HeroTitle } from './hero-title';
interface HeroProps {
pill?: React.ReactNode;
title: React.ReactNode;
subtitle?: React.ReactNode;
cta?: React.ReactNode;
image?: React.ReactNode;
className?: string;
animate?: boolean;
}
export function Hero({
pill,
title,
subtitle,
cta,
image,
className,
animate = true,
}: HeroProps) {
return (
<div className={cn('mx-auto flex flex-col space-y-20', className)}>
<div
className={cn(
'mx-auto flex flex-1 flex-col items-center justify-center md:flex-row',
{
['duration-1000 animate-in fade-in zoom-in-90 slide-in-from-top-36']:
animate,
},
)}
>
<div className="flex w-full flex-1 flex-col items-center space-y-6 xl:space-y-8 2xl:space-y-10">
{pill && (
<div
className={cn({
['delay-300 duration-700 animate-in fade-in fill-mode-both']:
animate,
})}
>
{pill}
</div>
)}
<div className="flex flex-col items-center space-y-8">
<HeroTitle>{title}</HeroTitle>
{subtitle && (
<div className="flex max-w-2xl flex-col space-y-1">
<Heading
level={3}
className="p-0 text-center font-sans text-base font-normal"
>
{subtitle}
</Heading>
</div>
)}
</div>
{cta && (
<div
className={cn({
['delay-500 duration-1000 animate-in fade-in fill-mode-both']:
animate,
})}
>
{cta}
</div>
)}
</div>
</div>
{image && (
<div
className={cn('mx-auto flex max-w-[85rem] justify-center py-8', {
['delay-300 duration-1000 animate-in fade-in zoom-in-95 slide-in-from-top-32 fill-mode-both']:
animate,
})}
>
{image}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,12 @@
export * from './hero-title';
export * from './pill';
export * from './gradient-secondary-text';
export * from './gradient-text';
export * from './hero';
export * from './secondary-hero';
export * from './cta-button';
export * from './header';
export * from './footer';
export * from './feature-showcase';
export * from './feature-grid';
export * from './feature-card';

View File

@@ -0,0 +1,40 @@
import { forwardRef } from 'react';
import { Slot, Slottable } from '@radix-ui/react-slot';
import { cn } from '../../utils';
import { GradientSecondaryText } from './gradient-secondary-text';
export const Pill = forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement> & {
label?: string;
asChild?: boolean;
}
>(function PillComponent({ className, asChild, ...props }, ref) {
const Comp = asChild ? Slot : 'h3';
return (
<Comp
ref={ref}
className={cn(
'space-x-2.5 rounded-full border border-gray-100 px-2 py-2.5 text-center text-sm font-medium text-transparent dark:border-primary/10',
className,
)}
{...props}
>
{props.label && (
<span
className={
'rounded-2xl bg-primary px-2.5 py-1.5 text-sm font-semibold text-primary-foreground'
}
>
{props.label}
</span>
)}
<Slottable>
<GradientSecondaryText>{props.children}</GradientSecondaryText>
</Slottable>
</Comp>
);
});

View File

@@ -0,0 +1,49 @@
import { forwardRef } from 'react';
import { Heading } from '../../shadcn/heading';
import { cn } from '../../utils';
interface SecondaryHeroProps extends React.HTMLAttributes<HTMLDivElement> {
pill?: React.ReactNode;
heading: React.ReactNode;
subheading: React.ReactNode;
}
export const SecondaryHero = forwardRef<HTMLDivElement, SecondaryHeroProps>(
function SecondaryHeroComponent(
{ className, pill, heading, subheading, children, ...props },
ref,
) {
return (
<div
ref={ref}
className={cn(
'flex flex-col items-center space-y-4 text-center',
className,
)}
{...props}
>
{pill && (
<div className="delay-300 duration-700 animate-in fade-in">
{pill}
</div>
)}
<div className="flex flex-col">
<Heading level={2} className="tracking-tighter">
{heading}
</Heading>
<Heading
level={3}
className="font-sans font-normal tracking-tight text-muted-foreground"
>
{subheading}
</Heading>
</div>
{children}
</div>
);
},
);