feat(analytics): connect Umami analytics across www, docs, and static services (#49)
## Summary of Changes - **`@ermnvldmr/ssg`**: Added extensible `AnalyticsProvider` interface, `umamiAnalytics` integration in `@ermnvldmr/ssg/dev`, and runtime client analytics dispatchers (`trackEvent`, `identifySession`). - **`services/docs`**: Configured native Hextra Umami parameters in `hugo.yaml` (`docs.ermnvldmr.com` with UUID `fbdadf3f-3484-4186-a2c2-cedcf76cf44f`). - **`services/www`**: Injected Umami tracking provider into `rsbuild.config.ts` (`ermnvldmr.com`, `www.ermnvldmr.com` with UUID `4fca5ac2-4fe4-4626-b708-933c37bfe918`). - **`services/static`**: Injected Umami tracking provider into `rsbuild.config.ts` (`static.ermnvldmr.com` with UUID `0831532a-2cec-4955-8f5b-452d8bba96d8`). - **Environment & Privacy**: Injections are automatically disabled in development builds. Configured `data-domains` filtering and `data-do-not-track="true"`. ## Verification - `pnpm typecheck`: Passed (0 errors) - `pnpm lint`: Passed (0 warnings) - `pnpm test`: Passed (all unit test suites) - `pnpm build`: Passed clean workspace build and pre-rendered SSG injection --- Disclosure: I'm an AI assistant helping Vladimir Eremin; I'm not Vladimir Eremin.
This commit is contained in:
1 parent
c8beec4d47
commit
ea805c724b
31 files changed
+622
-60
No files matched your search
@@ -1,9 +1,9 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cn, castRef, filterDataAttributes, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef, useRef } from 'react';
|
||||
import { useButton, useLink, useFocusRing, useHover } from 'react-aria';
|
||||
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
import type { ClassNameProps, DataAttributes, TestIdProps } from '@ermnvldmr/stl';
|
||||
import type { AriaButtonProps, AriaLinkOptions } from 'react-aria';
|
||||
|
||||
/**
|
||||
@@ -34,7 +34,7 @@ export type ButtonSize = 's' | 'm' | 'l';
|
||||
/**
|
||||
* Props shared by both action and link button variants.
|
||||
*/
|
||||
interface BaseButtonProps extends ClassNameProps, TestIdProps {
|
||||
interface BaseButtonProps extends ClassNameProps, TestIdProps, DataAttributes {
|
||||
/** Content to be rendered inside the button */
|
||||
children?: React.ReactNode;
|
||||
/** Visual style variant */
|
||||
@@ -260,12 +260,15 @@ const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(pro
|
||||
</>
|
||||
);
|
||||
|
||||
const dataAttributes = filterDataAttributes(props);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
{...linkProps}
|
||||
{...focusProps}
|
||||
{...hoverProps}
|
||||
{...dataAttributes}
|
||||
ref={castRef<HTMLAnchorElement>(targetRef)}
|
||||
className={sharedClasses}
|
||||
data-testid={testId}
|
||||
@@ -280,6 +283,7 @@ const ButtonComponent = forwardRef<HTMLElement, ButtonProps>(function Button(pro
|
||||
{...buttonProps}
|
||||
{...focusProps}
|
||||
{...hoverProps}
|
||||
{...dataAttributes}
|
||||
ref={castRef<HTMLButtonElement>(targetRef)}
|
||||
className={sharedClasses}
|
||||
data-testid={testId}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { castRef, cn, genericMemo } from '@ermnvldmr/stl';
|
||||
import { castRef, cn, filterDataAttributes, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
import { usePress } from 'react-aria';
|
||||
|
||||
import type { SpacingScale } from '../../lib/scale';
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
import type { ClassNameProps, DataAttributes, TestIdProps } from '@ermnvldmr/stl';
|
||||
import type { PressEvents } from 'react-aria';
|
||||
|
||||
/**
|
||||
@@ -32,7 +32,7 @@ export type ContainerRounded = 'none' | 'sm' | 'md' | 'lg' | 'full';
|
||||
/**
|
||||
* Props for the Container component.
|
||||
*/
|
||||
export interface ContainerProps extends ClassNameProps, TestIdProps {
|
||||
export interface ContainerProps extends ClassNameProps, TestIdProps, DataAttributes {
|
||||
/** Content to be rendered inside the container */
|
||||
children: React.ReactNode;
|
||||
/** Background color variant */
|
||||
@@ -153,7 +153,10 @@ const containerVariants = cva('overflow-hidden', {
|
||||
* borders, and width constraints while maintaining theme consistency.
|
||||
*/
|
||||
const ContainerComponent = forwardRef<HTMLElement, ContainerProps>(function Container(
|
||||
{
|
||||
props,
|
||||
ref
|
||||
) {
|
||||
const {
|
||||
children,
|
||||
bg = 'base',
|
||||
padding = 0,
|
||||
@@ -166,9 +169,8 @@ const ContainerComponent = forwardRef<HTMLElement, ContainerProps>(function Cont
|
||||
as: Component = 'div',
|
||||
className,
|
||||
'data-testid': testId,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
} = props;
|
||||
|
||||
if (process.env.NODE_ENV !== 'production' && onPress && href) {
|
||||
console.warn('[@ermnvldmr/ui] Container cannot have both `onPress` and `href` props.');
|
||||
}
|
||||
@@ -183,10 +185,12 @@ const ContainerComponent = forwardRef<HTMLElement, ContainerProps>(function Cont
|
||||
|
||||
const interactionProps = isPressable ? pressProps : {};
|
||||
const FinalComponent = isLink ? 'a' : Component;
|
||||
const dataAttributes = filterDataAttributes(props);
|
||||
|
||||
return (
|
||||
<FinalComponent
|
||||
{...interactionProps}
|
||||
{...dataAttributes}
|
||||
ref={castRef<HTMLElement>(ref)}
|
||||
className={cn(
|
||||
containerVariants({
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { localePath } from '@ermnvldmr/i18n';
|
||||
import { castRef, cn } from '@ermnvldmr/stl';
|
||||
import { castRef, cn, filterDataAttributes } from '@ermnvldmr/stl';
|
||||
import React, { memo, useRef } from 'react';
|
||||
import { useLink } from 'react-aria';
|
||||
|
||||
import { Text } from '../Text/Text';
|
||||
|
||||
import type { TextColor, TextSize, TextType } from '../Text/Text';
|
||||
import type { ClassNameProps } from '@ermnvldmr/stl';
|
||||
import type { ClassNameProps, DataAttributes } from '@ermnvldmr/stl';
|
||||
import type { AriaLinkOptions } from 'react-aria';
|
||||
|
||||
/**
|
||||
* Props for the Link component.
|
||||
*/
|
||||
export interface LinkProps extends AriaLinkOptions, ClassNameProps {
|
||||
export interface LinkProps extends AriaLinkOptions, ClassNameProps, DataAttributes {
|
||||
/** The destination URL. Renders as an <a> tag if provided. */
|
||||
href?: string;
|
||||
/** Link content */
|
||||
@@ -50,6 +50,8 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
className
|
||||
);
|
||||
|
||||
const dataAttributes = filterDataAttributes(props);
|
||||
|
||||
if (href) {
|
||||
const resolvedHref = localePath(href);
|
||||
const isAutoExternal = isExternal ?? href.startsWith('http');
|
||||
@@ -59,6 +61,7 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
<Text
|
||||
{...linkProps}
|
||||
{...externalProps}
|
||||
{...dataAttributes}
|
||||
ref={ref}
|
||||
as="a"
|
||||
className={sharedClasses}
|
||||
@@ -76,6 +79,7 @@ export const Link = memo(function Link(props: LinkProps) {
|
||||
return (
|
||||
<Text
|
||||
{...linkProps}
|
||||
{...dataAttributes}
|
||||
ref={castRef<HTMLSpanElement>(ref)}
|
||||
as="span"
|
||||
className={sharedClasses}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
/**
|
||||
* Function signature for telemetry and event logging.
|
||||
*/
|
||||
export type LogFn = (event: string, data?: Record<string, string | number | boolean>) => void;
|
||||
|
||||
const noopLogger: LogFn = () => undefined;
|
||||
|
||||
/**
|
||||
* React context holding the active logging function.
|
||||
*/
|
||||
export const LogContext = createContext<LogFn>(noopLogger);
|
||||
|
||||
/**
|
||||
* Hook to access the logging function from the nearest LogProvider.
|
||||
*
|
||||
* Defaults to a safe no-op if rendered outside a LogProvider.
|
||||
*
|
||||
* @returns The active logging function.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const log = useLogger();
|
||||
* log('button-click', { buttonId: 'submit' });
|
||||
* ```
|
||||
*/
|
||||
export function useLogger(): LogFn {
|
||||
return useContext(LogContext);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useLogger } from './LogContext';
|
||||
import { LogProvider } from './LogProvider';
|
||||
import { Button } from '../Button/Button';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
function LogDemo(): React.JSX.Element {
|
||||
const log = useLogger();
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
onPress={() => {
|
||||
log('story-click', { timestamp: Date.now() });
|
||||
}}
|
||||
>
|
||||
Click to Log Event
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof LogProvider> = {
|
||||
title: 'Components/LogProvider',
|
||||
component: LogProvider,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LogProvider>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<LogProvider
|
||||
logger={(event, data) => {
|
||||
console.log(`[Event: ${event}]`, data);
|
||||
}}
|
||||
>
|
||||
<LogDemo />
|
||||
</LogProvider>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useLogger } from './LogContext';
|
||||
import { LogProvider } from './LogProvider';
|
||||
|
||||
function ConsumerComponent(): React.JSX.Element {
|
||||
const log = useLogger();
|
||||
return (
|
||||
<button type="button" onClick={() => log('test-event', { key: 'value' })}>
|
||||
Trigger Log
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
describe('LogProvider', () => {
|
||||
it('defaults to no-op when outside LogProvider', () => {
|
||||
render(<ConsumerComponent />);
|
||||
const button = screen.getByRole('button', { name: 'Trigger Log' });
|
||||
expect(() => button.click()).not.toThrow();
|
||||
});
|
||||
|
||||
it('calls the provided logger on event dispatch', () => {
|
||||
const loggerMock = vi.fn();
|
||||
render(
|
||||
<LogProvider logger={loggerMock}>
|
||||
<ConsumerComponent />
|
||||
</LogProvider>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Trigger Log' });
|
||||
button.click();
|
||||
|
||||
expect(loggerMock).toHaveBeenCalledWith('test-event', { key: 'value' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { LogContext } from './LogContext';
|
||||
|
||||
import type { LogFn } from './LogContext';
|
||||
|
||||
/**
|
||||
* Props for the LogProvider component.
|
||||
*/
|
||||
export interface LogProviderProps {
|
||||
/** The telemetry logging function to inject into the React tree. */
|
||||
logger?: LogFn;
|
||||
/** Children components that will have access to the logger via useLogger. */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider component for injecting analytics and telemetry loggers into the UI component tree.
|
||||
*
|
||||
* @param props - Component props containing optional logger and children.
|
||||
* @returns React context provider element.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LogProvider logger={trackEvent}>
|
||||
* <App />
|
||||
* </LogProvider>
|
||||
* ```
|
||||
*/
|
||||
export function LogProvider({ logger, children }: LogProviderProps): React.JSX.Element {
|
||||
const value: LogFn = useMemo(() => logger ?? (() => undefined), [logger]);
|
||||
|
||||
return <LogContext.Provider value={value}>{children}</LogContext.Provider>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './LogContext';
|
||||
export * from './LogProvider';
|
||||
@@ -1,9 +1,9 @@
|
||||
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cn, castRef, filterDataAttributes, genericMemo } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { forwardRef } from 'react';
|
||||
|
||||
import type { SpacingScale } from '../../lib/scale';
|
||||
import type { ClassNameProps, TestIdProps } from '@ermnvldmr/stl';
|
||||
import type { ClassNameProps, DataAttributes, TestIdProps } from '@ermnvldmr/stl';
|
||||
|
||||
/**
|
||||
* Flexbox direction values.
|
||||
@@ -28,7 +28,7 @@ type FlexWrap = 'nowrap' | 'wrap' | 'wrap-reverse';
|
||||
/**
|
||||
* Props for the flexible Stack layout component.
|
||||
*/
|
||||
export interface StackProps extends ClassNameProps, TestIdProps {
|
||||
export interface StackProps extends ClassNameProps, TestIdProps, DataAttributes {
|
||||
/** Content to be arranged in the stack */
|
||||
children: React.ReactNode;
|
||||
/** Flexbox direction (row, column, or their reverse variants) */
|
||||
@@ -165,7 +165,10 @@ const stackVariants = cva('flex', {
|
||||
* ```
|
||||
*/
|
||||
const StackComponent = forwardRef<HTMLElement, StackProps>(function Stack(
|
||||
{
|
||||
props,
|
||||
ref
|
||||
) {
|
||||
const {
|
||||
children,
|
||||
direction = 'row',
|
||||
justify = 'start',
|
||||
@@ -176,11 +179,13 @@ const StackComponent = forwardRef<HTMLElement, StackProps>(function Stack(
|
||||
as: Component = 'div',
|
||||
className,
|
||||
'data-testid': testId,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
} = props;
|
||||
|
||||
const dataAttributes = filterDataAttributes(props);
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...dataAttributes}
|
||||
ref={castRef<HTMLElement>(ref)}
|
||||
className={cn(
|
||||
stackVariants({ direction, justify, align, wrap, gap, scrollable }),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cn } from '@ermnvldmr/stl';
|
||||
import { cn, filterDataAttributes } from '@ermnvldmr/stl';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React, { useRef } from 'react';
|
||||
import { useSwitch, useFocusRing, VisuallyHidden } from 'react-aria';
|
||||
@@ -6,6 +6,7 @@ import { useToggleState } from 'react-stately';
|
||||
|
||||
import { Text } from '../Text/Text';
|
||||
|
||||
import type { ClassNameProps, DataAttributes } from '@ermnvldmr/stl';
|
||||
import type { AriaSwitchProps } from 'react-aria';
|
||||
|
||||
/**
|
||||
@@ -31,7 +32,7 @@ export type SwitchColor =
|
||||
/**
|
||||
* Props for the Switch component.
|
||||
*/
|
||||
export interface SwitchProps extends AriaSwitchProps {
|
||||
export interface SwitchProps extends AriaSwitchProps, ClassNameProps, DataAttributes {
|
||||
/** Optional class name for the outer container */
|
||||
className?: string;
|
||||
/** Optional label for the switch */
|
||||
@@ -220,9 +221,11 @@ export function Switch(props: SwitchProps): React.JSX.Element {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const { inputProps } = useSwitch(props, state, ref);
|
||||
const { isFocusVisible, focusProps } = useFocusRing();
|
||||
const dataAttributes = filterDataAttributes(props);
|
||||
|
||||
return (
|
||||
<label
|
||||
{...dataAttributes}
|
||||
className={cn(
|
||||
'group inline-flex items-center cursor-pointer select-none gap-3',
|
||||
props.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
|
||||
@@ -10,6 +10,7 @@ export * from './Icon/index';
|
||||
export * from './Image/Image';
|
||||
export * from './Link/Link';
|
||||
export * from './List/List';
|
||||
export * from './LogProvider/index';
|
||||
export * from './Paragraph/Paragraph';
|
||||
export * from './Separator/Separator';
|
||||
export * from './Stack/Stack';
|
||||
|
||||
Reference in new issue
Block a user