chore: update CHANGELOG.md and bump version to 0.5.0-alpha, highlighting analytics chart improvements and new export functionality

This commit is contained in:
Usman Baig
2026-02-11 20:04:33 +01:00
parent c79e767152
commit c623ae1e9b
4 changed files with 227 additions and 52 deletions

View File

@@ -4,38 +4,41 @@ All notable changes to Pulse (frontend and product) are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**.
## [0.5.0-alpha] - 2026-02-11
### Changed
- **Analytics chart improvements.** Clearer labels (including what the chart measures), compare mode shows which period you're comparing against, mini trend lines on each stat, export chart as image, and a better experience on mobile.
## [0.4.0-alpha] - 2026-02-11 ## [0.4.0-alpha] - 2026-02-11
### Changed ### Changed
- **Campaigns block improvements (PULSE-53).** The Campaigns card now supports sortable columns (Source, Medium, Campaign, Visitors, Pageviews), source favicons with display names (matching Top Referrers), a Pageviews column, and em-dash (—) for empty Medium/Campaign. Loading state uses a skeleton instead of a spinner. Rows use stable keys for better React reconciliation. An Export button exports campaigns to CSV; the main dashboard Export (PDF/Excel) also includes campaigns when available. - **Campaigns block improvements (PULSE-53).** Sortable columns, favicons and friendly names for sources, pageviews column, and export to CSV. Full dashboard export now includes campaigns.
## [0.3.0-alpha] - 2026-02-11 ## [0.3.0-alpha] - 2026-02-11
### Changed ### Changed
- **Top Referrers favicons (PULSE-52).** The Top Referrers card now shows real site favicons (e.g. Google, ChatGPT, Instagram) when the referrer is a domain or URL. “Direct” and “Unknown” keep the globe icon; if a favicon fails to load, the previous icon is shown as fallback. - **Top Referrers favicons and names (PULSE-52).** Real favicons (Google, ChatGPT, etc.) and friendly names instead of raw URLs. Same referrer from different URLs is merged into one row.
- **Referrer display names.** Referrers now show friendly names (e.g. “Google”, “Kagi”) using a heuristic from the hostname plus a small override map for famous brands (ChatGPT, LinkedIn, X, etc.). New sites get a sensible name without being added to a list.
- **Top Referrers merged by name.** Rows that map to the same display name (e.g. `chatgpt.com` and `https://chatgpt.com/...`) are merged into one row with combined pageviews, so the same source no longer appears twice.
## [0.2.0-alpha] - 2026-02-11 ## [0.2.0-alpha] - 2026-02-11
### Added ### Added
- **Smarter unique visitor counts.** If someone opens your site in several tabs or windows, theyre now counted as one visitor by default, so your stats better reflect real people. - **Smarter unique visitor counts.** Visitors opening several tabs/windows are counted as one person.
- **Control over how visitors are counted.** You can switch back to “one visitor per tab (more private, no lasting identifier) by adding an option to your script embed. The dashboard shows the right snippet for both options. - **Visitor count options.** Choose "one per tab" (more private) or "one per person" (default). Dashboard shows the right embed snippet for each.
- **Optional expiry for the visitor ID.** You can set how long the cross-tab visitor ID is kept (e.g. 24 hours); after that its refreshed automatically.
## [0.1.0-alpha] - 2026-02-09 ## [0.1.0-alpha] - 2026-02-09
### Added ### Added
- Initial changelog and release process. - Initial changelog and release process.
- Release documentation in `docs/releasing.md` and optional changelog check script.
--- ---
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.4.0-alpha...HEAD [Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.5.0-alpha...HEAD
[0.5.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.4.0-alpha...v0.5.0-alpha
[0.4.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.3.0-alpha...v0.4.0-alpha [0.4.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.3.0-alpha...v0.4.0-alpha
[0.3.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.2.0-alpha...v0.3.0-alpha [0.3.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.2.0-alpha...v0.3.0-alpha
[0.2.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.1.0-alpha...v0.2.0-alpha [0.2.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.1.0-alpha...v0.2.0-alpha

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useMemo } from 'react' import { useState, useMemo, useRef, useCallback } from 'react'
import { useTheme } from '@ciphera-net/ui' import { useTheme } from '@ciphera-net/ui'
import { import {
AreaChart, AreaChart,
@@ -11,10 +11,11 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
ReferenceLine, ReferenceLine,
Label,
} from 'recharts' } from 'recharts'
import type { TooltipProps } from 'recharts' import type { TooltipProps } from 'recharts'
import { formatNumber, formatDuration } from '@/lib/utils/format' import { formatNumber, formatDuration } from '@/lib/utils/format'
import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select } from '@ciphera-net/ui' import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, Button, DownloadIcon } from '@ciphera-net/ui'
import { Checkbox } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui'
const COLORS = { const COLORS = {
@@ -67,6 +68,8 @@ interface ChartProps {
setTodayInterval: (interval: 'minute' | 'hour') => void setTodayInterval: (interval: 'minute' | 'hour') => void
multiDayInterval: 'hour' | 'day' multiDayInterval: 'hour' | 'day'
setMultiDayInterval: (interval: 'hour' | 'day') => void setMultiDayInterval: (interval: 'hour' | 'day') => void
/** Optional: callback when user requests chart export (parent can open ExportModal or handle export) */
onExportChart?: () => void
} }
type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration' type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration'
@@ -80,6 +83,7 @@ function ChartTooltip({
metricLabel, metricLabel,
formatNumberFn, formatNumberFn,
showComparison, showComparison,
prevPeriodLabel,
colors, colors,
}: { }: {
active?: boolean active?: boolean
@@ -89,6 +93,7 @@ function ChartTooltip({
metricLabel: string metricLabel: string
formatNumberFn: (n: number) => string formatNumberFn: (n: number) => string
showComparison: boolean showComparison: boolean
prevPeriodLabel?: string
colors: typeof CHART_COLORS_LIGHT colors: typeof CHART_COLORS_LIGHT
}) { }) {
if (!active || !payload?.length || !label) return null if (!active || !payload?.length || !label) return null
@@ -140,7 +145,7 @@ function ChartTooltip({
</div> </div>
{hasPrev && ( {hasPrev && (
<div className="mt-1.5 flex items-center gap-1.5 text-xs" style={{ color: colors.textMuted }}> <div className="mt-1.5 flex items-center gap-1.5 text-xs" style={{ color: colors.textMuted }}>
<span>vs {formatValue(prev as number)} prev</span> <span>vs {formatValue(prev as number)} {prevPeriodLabel ? `(${prevPeriodLabel})` : 'prev'}</span>
{delta !== null && ( {delta !== null && (
<span <span
className="font-medium" className="font-medium"
@@ -164,6 +169,80 @@ function formatAxisValue(value: number): string {
return String(value) return String(value)
} }
// * Returns human-readable label for the previous comparison period (e.g. "Feb 10" or "Jan 5 Feb 4")
function getPrevDateRangeLabel(dateRange: { start: string; end: string }): string {
const startDate = new Date(dateRange.start)
const endDate = new Date(dateRange.end)
const duration = endDate.getTime() - startDate.getTime()
if (duration === 0) {
const prev = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
return prev.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
const prevStart = new Date(prevEnd.getTime() - duration)
const fmt = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
return `${fmt(prevStart)} ${fmt(prevEnd)}`
}
// * Returns short trend context (e.g. "vs yesterday", "vs previous 7 days")
function getTrendContext(dateRange: { start: string; end: string }): string {
const startDate = new Date(dateRange.start)
const endDate = new Date(dateRange.end)
const duration = endDate.getTime() - startDate.getTime()
if (duration === 0) return 'vs yesterday'
const days = Math.round(duration / (24 * 60 * 60 * 1000))
if (days === 1) return 'vs yesterday'
return `vs previous ${days} days`
}
// * Mini sparkline SVG for KPI cards
function Sparkline({
data,
dataKey,
color,
width = 56,
height = 20,
}: {
data: Array<Record<string, unknown>>
dataKey: string
color: string
width?: number
height?: number
}) {
if (!data.length) return null
const values = data.map((d) => Number(d[dataKey] ?? 0))
const max = Math.max(...values, 1)
const min = Math.min(...values, 0)
const range = max - min || 1
const padding = 2
const w = width - padding * 2
const h = height - padding * 2
const points = values.map((v, i) => {
const x = padding + (i / Math.max(values.length - 1, 1)) * w
const y = padding + h - ((v - min) / range) * h
return `${x},${y}`
})
const pathD = points.length > 1 ? `M ${points.join(' L ')}` : `M ${points[0]} L ${points[0]}`
return (
<svg width={width} height={height} className="flex-shrink-0" aria-hidden>
<path
d={pathD}
fill="none"
stroke={color}
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export default function Chart({ export default function Chart({
data, data,
prevData, prevData,
@@ -174,12 +253,35 @@ export default function Chart({
todayInterval, todayInterval,
setTodayInterval, setTodayInterval,
multiDayInterval, multiDayInterval,
setMultiDayInterval setMultiDayInterval,
onExportChart,
}: ChartProps) { }: ChartProps) {
const [metric, setMetric] = useState<MetricType>('visitors') const [metric, setMetric] = useState<MetricType>('visitors')
const [showComparison, setShowComparison] = useState(false) const [showComparison, setShowComparison] = useState(false)
const chartContainerRef = useRef<HTMLDivElement>(null)
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const handleExportChart = useCallback(async () => {
if (onExportChart) {
onExportChart()
return
}
if (!chartContainerRef.current) return
try {
const { toPng } = await import('html-to-image')
const dataUrl = await toPng(chartContainerRef.current, {
cacheBust: true,
backgroundColor: resolvedTheme === 'dark' ? '#171717' : '#ffffff',
})
const link = document.createElement('a')
link.download = `chart-${dateRange.start}-${dateRange.end}.png`
link.href = dataUrl
link.click()
} catch {
// Fallback: do nothing if export fails
}
}, [onExportChart, dateRange, resolvedTheme])
const colors = useMemo( const colors = useMemo(
() => (resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT), () => (resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT),
[resolvedTheme] [resolvedTheme]
@@ -265,12 +367,16 @@ export default function Chart({
const activeMetric = metrics.find((m) => m.id === metric) || metrics[0] const activeMetric = metrics.find((m) => m.id === metric) || metrics[0]
const chartMetric = metric const chartMetric = metric
const metricLabel = metrics.find(m => m.id === metric)?.label || 'visitors' const metricLabel = metrics.find(m => m.id === metric)?.label || 'visitors'
const prevPeriodLabel = prevData?.length ? getPrevDateRangeLabel(dateRange) : ''
const trendContext = prevStats ? getTrendContext(dateRange) : ''
const avg = chartData.length const avg = chartData.length
? chartData.reduce((s, d) => s + (d[chartMetric] as number), 0) / chartData.length ? chartData.reduce((s, d) => s + (d[chartMetric] as number), 0) / chartData.length
: 0 : 0
const hasPrev = !!(prevData?.length && showComparison) const hasPrev = !!(prevData?.length && showComparison)
const hasData = data.length > 0
const hasAnyNonZero = hasData && chartData.some((d) => (d[chartMetric] as number) > 0)
// * In hourly view, only show X-axis labels at 12:00 AM (date + 12:00 AM). // * In hourly view, only show X-axis labels at 12:00 AM (date + 12:00 AM).
const midnightTicks = const midnightTicks =
@@ -290,25 +396,33 @@ export default function Chart({
const dayTicks = interval === 'day' && chartData.length > 0 ? chartData.map((c) => c.date) : undefined const dayTicks = interval === 'day' && chartData.length > 0 ? chartData.map((c) => c.date) : undefined
return ( return (
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm"> <div
ref={chartContainerRef}
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm"
role="region"
aria-label={`Analytics chart showing ${metricLabel} over time`}
>
{/* Stats Header (Interactive Tabs) */} {/* Stats Header (Interactive Tabs) */}
<div className="grid grid-cols-2 md:grid-cols-4 divide-x divide-neutral-200 dark:divide-neutral-800 border-b border-neutral-200 dark:border-neutral-800"> <div className="grid grid-cols-2 md:grid-cols-4 divide-x divide-neutral-200 dark:divide-neutral-800 border-b border-neutral-200 dark:border-neutral-800">
{metrics.map((item) => ( {metrics.map((item) => (
<button <button
key={item.id} key={item.id}
type="button"
onClick={() => setMetric(item.id as MetricType)} onClick={() => setMetric(item.id as MetricType)}
aria-pressed={metric === item.id}
aria-label={`Show ${item.label} chart`}
className={` className={`
p-6 text-left transition-colors relative group p-4 sm:p-6 text-left transition-colors relative group
hover:bg-neutral-50 dark:hover:bg-neutral-800/50 hover:bg-neutral-50 dark:hover:bg-neutral-800/50
${metric === item.id ? 'bg-neutral-50 dark:bg-neutral-800/50' : ''} ${metric === item.id ? 'bg-neutral-50 dark:bg-neutral-800/50' : ''}
cursor-pointer cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-500 focus-visible:ring-offset-2
`} `}
> >
<div className={`text-xs font-semibold uppercase tracking-wider mb-1 flex items-center gap-2 ${metric === item.id ? 'text-neutral-900 dark:text-white' : 'text-neutral-500'}`}> <div className={`text-xs font-semibold uppercase tracking-wider mb-1 flex items-center gap-2 ${metric === item.id ? 'text-neutral-900 dark:text-white' : 'text-neutral-500'}`}>
{item.label} {item.label}
</div> </div>
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2 flex-wrap">
<span className="text-2xl font-bold text-neutral-900 dark:text-white"> <span className="text-xl sm:text-2xl font-bold text-neutral-900 dark:text-white">
{item.value} {item.value}
</span> </span>
{item.trend !== null && ( {item.trend !== null && (
@@ -328,6 +442,14 @@ export default function Chart({
</span> </span>
)} )}
</div> </div>
{trendContext && item.trend !== null && (
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{trendContext}</p>
)}
{hasData && (
<div className="mt-2">
<Sparkline data={chartData} dataKey={item.id} color={item.color} />
</div>
)}
{metric === item.id && ( {metric === item.id && (
<div className="absolute bottom-0 left-0 right-0 h-1" style={{ backgroundColor: item.color }} /> <div className="absolute bottom-0 left-0 right-0 h-1" style={{ backgroundColor: item.color }} />
)} )}
@@ -355,51 +477,71 @@ export default function Chart({
className="h-2 w-2 rounded-full border border-dashed" className="h-2 w-2 rounded-full border border-dashed"
style={{ borderColor: colors.axis }} style={{ borderColor: colors.axis }}
/> />
Previous Previous{prevPeriodLabel ? ` (${prevPeriodLabel})` : ''}
</span> </span>
</div> </div>
)} )}
</div> </div>
{/* Right side: Controls */} {/* Right side: Controls */}
<div className="flex items-center gap-3 self-end sm:self-auto"> <div className="flex flex-wrap items-center gap-3 self-end sm:self-auto">
{dateRange.start === dateRange.end && ( <div className="flex items-center gap-2">
<Select <span className="text-xs text-neutral-500 dark:text-neutral-400">Group by:</span>
value={todayInterval} {dateRange.start === dateRange.end && (
onChange={(value) => setTodayInterval(value as 'minute' | 'hour')} <Select
options={[ value={todayInterval}
{ value: 'minute', label: '1 min' }, onChange={(value) => setTodayInterval(value as 'minute' | 'hour')}
{ value: 'hour', label: '1 hour' }, options={[
]} { value: 'minute', label: '1 min' },
className="min-w-[100px]" { value: 'hour', label: '1 hour' },
/> ]}
)} className="min-w-[100px]"
{dateRange.start !== dateRange.end && ( />
<Select )}
value={multiDayInterval} {dateRange.start !== dateRange.end && (
onChange={(value) => setMultiDayInterval(value as 'hour' | 'day')} <Select
options={[ value={multiDayInterval}
{ value: 'hour', label: '1 hour' }, onChange={(value) => setMultiDayInterval(value as 'hour' | 'day')}
{ value: 'day', label: '1 day' }, options={[
]} { value: 'hour', label: '1 hour' },
className="min-w-[100px]" { value: 'day', label: '1 day' },
/> ]}
)} className="min-w-[100px]"
/>
)}
</div>
{prevData?.length ? ( {prevData?.length ? (
<Checkbox <div className="flex flex-col gap-0.5">
checked={showComparison} <Checkbox
onCheckedChange={setShowComparison} checked={showComparison}
label="Compare" onCheckedChange={setShowComparison}
/> label="Compare with previous period"
/>
{showComparison && prevPeriodLabel && (
<span className="text-xs text-neutral-500 dark:text-neutral-400">
({prevPeriodLabel})
</span>
)}
</div>
) : null} ) : null}
<Button
variant="ghost"
onClick={handleExportChart}
disabled={!hasData}
className="gap-1.5 py-1.5 px-3 text-sm text-neutral-600 dark:text-neutral-400"
>
<DownloadIcon className="w-4 h-4" />
Export chart
</Button>
{/* Vertical Separator */} {/* Vertical Separator */}
<div className="h-4 w-px bg-neutral-200 dark:bg-neutral-800" /> <div className="h-4 w-px bg-neutral-200 dark:bg-neutral-800" />
</div> </div>
</div> </div>
{data.length === 0 ? ( {!hasData ? (
<div className="flex h-[320px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30"> <div className="flex h-[320px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30">
<BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden /> <BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden />
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400"> <p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
@@ -407,6 +549,14 @@ export default function Chart({
</p> </p>
<p className="text-xs text-neutral-400 dark:text-neutral-500">Try a different date range</p> <p className="text-xs text-neutral-400 dark:text-neutral-500">Try a different date range</p>
</div> </div>
) : !hasAnyNonZero ? (
<div className="flex h-[320px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30">
<BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden />
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
No {metricLabel.toLowerCase()} data for this period
</p>
<p className="text-xs text-neutral-400 dark:text-neutral-500">Try selecting another metric or date range</p>
</div>
) : ( ) : (
<div className="h-[360px] w-full"> <div className="h-[360px] w-full">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
@@ -439,7 +589,14 @@ export default function Chart({
if (metric === 'avg_duration') return formatDuration(val) if (metric === 'avg_duration') return formatDuration(val)
return formatAxisValue(val) return formatAxisValue(val)
}} }}
/> >
<Label
value={metricLabel}
position="insideTopLeft"
offset={8}
style={{ fill: colors.axis, fontSize: 11, fontWeight: 500 }}
/>
</YAxis>
<Tooltip <Tooltip
content={(p: TooltipProps<number, string>) => ( content={(p: TooltipProps<number, string>) => (
<ChartTooltip <ChartTooltip
@@ -453,6 +610,7 @@ export default function Chart({
metricLabel={metricLabel} metricLabel={metricLabel}
formatNumberFn={formatNumber} formatNumberFn={formatNumber}
showComparison={hasPrev} showComparison={hasPrev}
prevPeriodLabel={prevPeriodLabel}
colors={colors} colors={colors}
/> />
)} )}
@@ -465,6 +623,12 @@ export default function Chart({
stroke={colors.axis} stroke={colors.axis}
strokeDasharray="4 4" strokeDasharray="4 4"
strokeOpacity={0.7} strokeOpacity={0.7}
label={{
value: `Avg: ${metric === 'bounce_rate' ? `${Math.round(avg)}%` : metric === 'avg_duration' ? formatDuration(avg) : formatAxisValue(avg)}`,
position: 'right',
fill: colors.axis,
fontSize: 11,
}}
/> />
)} )}

11
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "pulse-frontend", "name": "pulse-frontend",
"version": "0.1.3", "version": "0.4.0-alpha",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-frontend", "name": "pulse-frontend",
"version": "0.1.3", "version": "0.4.0-alpha",
"dependencies": { "dependencies": {
"@ciphera-net/ui": "^0.0.49", "@ciphera-net/ui": "^0.0.49",
"@ducanh2912/next-pwa": "^10.2.9", "@ducanh2912/next-pwa": "^10.2.9",
@@ -14,6 +14,7 @@
"country-flag-icons": "^1.6.4", "country-flag-icons": "^1.6.4",
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
"framer-motion": "^12.23.26", "framer-motion": "^12.23.26",
"html-to-image": "^1.11.13",
"i18n-iso-countries": "^7.14.0", "i18n-iso-countries": "^7.14.0",
"iso-3166-2": "^1.0.0", "iso-3166-2": "^1.0.0",
"jspdf": "^4.0.0", "jspdf": "^4.0.0",
@@ -6092,6 +6093,12 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/html-to-image": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
"license": "MIT"
},
"node_modules/html-url-attributes": { "node_modules/html-url-attributes": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "pulse-frontend", "name": "pulse-frontend",
"version": "0.4.0-alpha", "version": "0.5.0-alpha",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
@@ -16,6 +16,7 @@
"country-flag-icons": "^1.6.4", "country-flag-icons": "^1.6.4",
"d3-scale": "^4.0.2", "d3-scale": "^4.0.2",
"framer-motion": "^12.23.26", "framer-motion": "^12.23.26",
"html-to-image": "^1.11.13",
"i18n-iso-countries": "^7.14.0", "i18n-iso-countries": "^7.14.0",
"iso-3166-2": "^1.0.0", "iso-3166-2": "^1.0.0",
"jspdf": "^4.0.0", "jspdf": "^4.0.0",