@happyvertical/smrt-marketing 0.39.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +79 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +67 -0
- package/dist/__smrt-register__.d.ts +2 -0
- package/dist/__smrt-register__.d.ts.map +1 -0
- package/dist/collections/CampaignChannelCollection.d.ts +9 -0
- package/dist/collections/CampaignChannelCollection.d.ts.map +1 -0
- package/dist/collections/CampaignCollection.d.ts +10 -0
- package/dist/collections/CampaignCollection.d.ts.map +1 -0
- package/dist/collections/CampaignMetricSnapshotCollection.d.ts +15 -0
- package/dist/collections/CampaignMetricSnapshotCollection.d.ts.map +1 -0
- package/dist/collections/index.d.ts +4 -0
- package/dist/collections/index.d.ts.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +625 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.json +1173 -0
- package/dist/models/Campaign.d.ts +31 -0
- package/dist/models/Campaign.d.ts.map +1 -0
- package/dist/models/CampaignChannel.d.ts +22 -0
- package/dist/models/CampaignChannel.d.ts.map +1 -0
- package/dist/models/CampaignMetricSnapshot.d.ts +29 -0
- package/dist/models/CampaignMetricSnapshot.d.ts.map +1 -0
- package/dist/models/index.d.ts +4 -0
- package/dist/models/index.d.ts.map +1 -0
- package/dist/services/BudgetPacingService.d.ts +25 -0
- package/dist/services/BudgetPacingService.d.ts.map +1 -0
- package/dist/services/CampaignLifecycleService.d.ts +19 -0
- package/dist/services/CampaignLifecycleService.d.ts.map +1 -0
- package/dist/services/MetricIngestionService.d.ts +16 -0
- package/dist/services/MetricIngestionService.d.ts.map +1 -0
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/smrt-knowledge.json +875 -0
- package/dist/svelte/__tests__/components.test.js +115 -0
- package/dist/svelte/__tests__/helpers.test.js +94 -0
- package/dist/svelte/components/BudgetPacing.svelte +129 -0
- package/dist/svelte/components/BudgetPacing.svelte.d.ts +10 -0
- package/dist/svelte/components/BudgetPacing.svelte.d.ts.map +1 -0
- package/dist/svelte/components/CampaignDetail.svelte +208 -0
- package/dist/svelte/components/CampaignDetail.svelte.d.ts +14 -0
- package/dist/svelte/components/CampaignDetail.svelte.d.ts.map +1 -0
- package/dist/svelte/components/CampaignList.svelte +140 -0
- package/dist/svelte/components/CampaignList.svelte.d.ts +11 -0
- package/dist/svelte/components/CampaignList.svelte.d.ts.map +1 -0
- package/dist/svelte/components/ChannelMix.svelte +138 -0
- package/dist/svelte/components/ChannelMix.svelte.d.ts +10 -0
- package/dist/svelte/components/ChannelMix.svelte.d.ts.map +1 -0
- package/dist/svelte/components/MarketingDashboard.svelte +129 -0
- package/dist/svelte/components/MarketingDashboard.svelte.d.ts +13 -0
- package/dist/svelte/components/MarketingDashboard.svelte.d.ts.map +1 -0
- package/dist/svelte/format.d.ts +8 -0
- package/dist/svelte/format.d.ts.map +1 -0
- package/dist/svelte/format.js +42 -0
- package/dist/svelte/index.d.ts +8 -0
- package/dist/svelte/index.d.ts.map +1 -0
- package/dist/svelte/index.js +7 -0
- package/dist/svelte/types.d.ts +73 -0
- package/dist/svelte/types.d.ts.map +1 -0
- package/dist/svelte/types.js +80 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +29 -0
- package/dist/types.js.map +1 -0
- package/package.json +79 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/svelte';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import BudgetPacing from '../components/BudgetPacing.svelte';
|
|
4
|
+
import CampaignDetail from '../components/CampaignDetail.svelte';
|
|
5
|
+
import CampaignList from '../components/CampaignList.svelte';
|
|
6
|
+
import ChannelMix from '../components/ChannelMix.svelte';
|
|
7
|
+
import MarketingDashboard from '../components/MarketingDashboard.svelte';
|
|
8
|
+
const campaign = {
|
|
9
|
+
id: 'campaign-1',
|
|
10
|
+
campaignKey: 'summer-launch',
|
|
11
|
+
name: 'Summer launch',
|
|
12
|
+
objective: 'lead_generation',
|
|
13
|
+
status: 'active',
|
|
14
|
+
startAt: '2026-07-01T00:00:00.000Z',
|
|
15
|
+
endAt: '2026-07-31T00:00:00.000Z',
|
|
16
|
+
budgetCents: 10_000,
|
|
17
|
+
currency: 'USD',
|
|
18
|
+
spendCents: 2_500,
|
|
19
|
+
impressions: 4_000,
|
|
20
|
+
clicks: 80,
|
|
21
|
+
conversions: 4,
|
|
22
|
+
leads: 12,
|
|
23
|
+
channelCount: 2,
|
|
24
|
+
};
|
|
25
|
+
const channels = [
|
|
26
|
+
{
|
|
27
|
+
id: 'channel-social',
|
|
28
|
+
channelKind: 'social',
|
|
29
|
+
channelRef: 'social:post-1',
|
|
30
|
+
label: 'Social post',
|
|
31
|
+
status: 'active',
|
|
32
|
+
allocatedBudgetCents: 7_500,
|
|
33
|
+
spendCents: 2_000,
|
|
34
|
+
impressions: 3_000,
|
|
35
|
+
clicks: 60,
|
|
36
|
+
leads: 10,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'channel-ad',
|
|
40
|
+
channelKind: 'ads',
|
|
41
|
+
channelRef: 'ads:campaign-1',
|
|
42
|
+
label: 'Paid campaign',
|
|
43
|
+
status: 'scheduled',
|
|
44
|
+
allocatedBudgetCents: 2_500,
|
|
45
|
+
spendCents: 500,
|
|
46
|
+
impressions: 1_000,
|
|
47
|
+
clicks: 20,
|
|
48
|
+
leads: 2,
|
|
49
|
+
},
|
|
50
|
+
];
|
|
51
|
+
describe('marketing Svelte surfaces', () => {
|
|
52
|
+
it('renders dashboard totals from public campaign props', () => {
|
|
53
|
+
render(MarketingDashboard, {
|
|
54
|
+
props: { campaigns: [campaign], locale: 'en-US' },
|
|
55
|
+
});
|
|
56
|
+
expect(screen.getByText('1 active')).toBeInTheDocument();
|
|
57
|
+
expect(screen.getByText('2')).toBeInTheDocument();
|
|
58
|
+
expect(screen.getByText('12')).toBeInTheDocument();
|
|
59
|
+
expect(screen.getByText('$25.00 spent')).toBeInTheDocument();
|
|
60
|
+
expect(screen.getByText('of $100.00 budget')).toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
it('renders campaign rows and reports selection through its callback', async () => {
|
|
63
|
+
const onSelect = vi.fn();
|
|
64
|
+
render(CampaignList, {
|
|
65
|
+
props: { campaigns: [campaign], locale: 'en-US', onSelect },
|
|
66
|
+
});
|
|
67
|
+
expect(screen.getByRole('table')).toBeInTheDocument();
|
|
68
|
+
expect(screen.getByText('Summer launch')).toBeInTheDocument();
|
|
69
|
+
await fireEvent.click(screen.getByRole('button', { name: 'View' }));
|
|
70
|
+
expect(onSelect).toHaveBeenCalledWith('campaign-1');
|
|
71
|
+
});
|
|
72
|
+
it('renders campaign details and generic channel references', () => {
|
|
73
|
+
const detail = {
|
|
74
|
+
...campaign,
|
|
75
|
+
description: 'A cross-channel launch.',
|
|
76
|
+
};
|
|
77
|
+
render(CampaignDetail, {
|
|
78
|
+
props: { campaign: detail, channels, locale: 'en-US' },
|
|
79
|
+
});
|
|
80
|
+
expect(screen.getByRole('heading', { name: 'Summer launch' })).toBeInTheDocument();
|
|
81
|
+
expect(screen.getByText('A cross-channel launch.')).toBeInTheDocument();
|
|
82
|
+
expect(screen.getByText('Social post')).toBeInTheDocument();
|
|
83
|
+
expect(screen.getByText('social:post-1')).toBeInTheDocument();
|
|
84
|
+
});
|
|
85
|
+
it('renders channel mix percentages and cent-based spend', () => {
|
|
86
|
+
render(ChannelMix, {
|
|
87
|
+
props: { channels, currency: 'USD', locale: 'en-US' },
|
|
88
|
+
});
|
|
89
|
+
expect(screen.getByRole('heading', { name: 'Channel mix' })).toBeInTheDocument();
|
|
90
|
+
expect(screen.getByText('$20.00 spent')).toBeInTheDocument();
|
|
91
|
+
expect(screen.getByText('80% of mix')).toBeInTheDocument();
|
|
92
|
+
expect(screen.getByText('$5.00 spent')).toBeInTheDocument();
|
|
93
|
+
expect(screen.getByText('20% of mix')).toBeInTheDocument();
|
|
94
|
+
});
|
|
95
|
+
it('renders budget pacing state and formatted evidence', () => {
|
|
96
|
+
const pacing = {
|
|
97
|
+
campaignId: campaign.id,
|
|
98
|
+
currency: 'USD',
|
|
99
|
+
budgetCents: 10_000,
|
|
100
|
+
spendCents: 4_000,
|
|
101
|
+
remainingCents: 6_000,
|
|
102
|
+
expectedSpendCents: 3_500,
|
|
103
|
+
varianceCents: 500,
|
|
104
|
+
budgetFraction: 0.4,
|
|
105
|
+
elapsedFraction: 0.35,
|
|
106
|
+
status: 'on_track',
|
|
107
|
+
};
|
|
108
|
+
render(BudgetPacing, { props: { pacing, locale: 'en-US' } });
|
|
109
|
+
expect(screen.getByRole('heading', { name: 'Budget pacing' })).toBeInTheDocument();
|
|
110
|
+
expect(screen.getByText('On track')).toBeInTheDocument();
|
|
111
|
+
expect(screen.getByText('$40.00 spent')).toBeInTheDocument();
|
|
112
|
+
expect(screen.getByText('40%')).toBeInTheDocument();
|
|
113
|
+
expect(screen.getByText('$60.00')).toBeInTheDocument();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatCents } from '../format.js';
|
|
3
|
+
import { calculateChannelMix, campaignStatusBadgeVariant, cappedBudgetProgress, pacingStatusBadgeVariant, summarizeMarketingDashboard, } from '../types.js';
|
|
4
|
+
describe('marketing Svelte helpers', () => {
|
|
5
|
+
it('keeps currency formatting render-safe for invalid inputs', () => {
|
|
6
|
+
expect(formatCents(12_345, 'NOT_A_CODE', 'en-US')).toBe('123.45 NOT_A_CODE');
|
|
7
|
+
expect(formatCents(Number.NaN, 'USD', 'en-US')).toBe('—');
|
|
8
|
+
});
|
|
9
|
+
it('summarizes campaign metrics without mixing currencies', () => {
|
|
10
|
+
const summary = summarizeMarketingDashboard([
|
|
11
|
+
{
|
|
12
|
+
id: 'campaign-1',
|
|
13
|
+
campaignKey: 'one',
|
|
14
|
+
name: 'One',
|
|
15
|
+
status: 'active',
|
|
16
|
+
budgetCents: 100_000,
|
|
17
|
+
spendCents: 40_000,
|
|
18
|
+
currency: 'CAD',
|
|
19
|
+
channelCount: 2,
|
|
20
|
+
impressions: 10_000,
|
|
21
|
+
clicks: 500,
|
|
22
|
+
conversions: 20,
|
|
23
|
+
leads: 12,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'campaign-2',
|
|
27
|
+
campaignKey: 'two',
|
|
28
|
+
name: 'Two',
|
|
29
|
+
status: 'completed',
|
|
30
|
+
budgetCents: 50_000,
|
|
31
|
+
spendCents: 45_000,
|
|
32
|
+
currency: 'CAD',
|
|
33
|
+
channelCount: 1,
|
|
34
|
+
leads: 5,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'campaign-3',
|
|
38
|
+
campaignKey: 'three',
|
|
39
|
+
name: 'Three',
|
|
40
|
+
status: 'draft',
|
|
41
|
+
budgetCents: 80_000,
|
|
42
|
+
spendCents: 0,
|
|
43
|
+
currency: 'USD',
|
|
44
|
+
},
|
|
45
|
+
]);
|
|
46
|
+
expect(summary.activeCampaignCount).toBe(1);
|
|
47
|
+
expect(summary.channelCount).toBe(3);
|
|
48
|
+
expect(summary.leads).toBe(17);
|
|
49
|
+
expect(summary.currencyTotals).toEqual([
|
|
50
|
+
{ currency: 'CAD', budgetCents: 150_000, spendCents: 85_000 },
|
|
51
|
+
{ currency: 'USD', budgetCents: 80_000, spendCents: 0 },
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
it('calculates channel spend and allocation fractions independently', () => {
|
|
55
|
+
const mix = calculateChannelMix([
|
|
56
|
+
{
|
|
57
|
+
id: 'ads',
|
|
58
|
+
channelKind: 'ad_group',
|
|
59
|
+
channelRef: 'ag-1',
|
|
60
|
+
status: 'active',
|
|
61
|
+
allocatedBudgetCents: 75_000,
|
|
62
|
+
spendCents: 30_000,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'social',
|
|
66
|
+
channelKind: 'social_post',
|
|
67
|
+
channelRef: 'post-1',
|
|
68
|
+
status: 'active',
|
|
69
|
+
allocatedBudgetCents: 25_000,
|
|
70
|
+
spendCents: 10_000,
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
expect(mix[0]?.spendFraction).toBe(0.75);
|
|
74
|
+
expect(mix[0]?.allocationFraction).toBe(0.75);
|
|
75
|
+
expect(mix[1]?.spendFraction).toBe(0.25);
|
|
76
|
+
});
|
|
77
|
+
it('caps visual progress while preserving status semantics', () => {
|
|
78
|
+
expect(cappedBudgetProgress({
|
|
79
|
+
campaignId: 'campaign-1',
|
|
80
|
+
currency: 'CAD',
|
|
81
|
+
budgetCents: 100,
|
|
82
|
+
spendCents: 125,
|
|
83
|
+
remainingCents: -25,
|
|
84
|
+
expectedSpendCents: 50,
|
|
85
|
+
varianceCents: 75,
|
|
86
|
+
budgetFraction: 1.25,
|
|
87
|
+
elapsedFraction: 0.5,
|
|
88
|
+
status: 'over_budget',
|
|
89
|
+
})).toBe(1);
|
|
90
|
+
expect(pacingStatusBadgeVariant('over_budget')).toBe('error');
|
|
91
|
+
expect(pacingStatusBadgeVariant('on_track')).toBe('success');
|
|
92
|
+
expect(campaignStatusBadgeVariant('paused')).toBe('warning');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Progress } from '@happyvertical/smrt-ui/feedback';
|
|
3
|
+
import { Badge, Card } from '@happyvertical/smrt-ui/ui';
|
|
4
|
+
import { formatCents, formatPercent, humanizeKey } from '../format.js';
|
|
5
|
+
import type { BudgetPacingView } from '../types.js';
|
|
6
|
+
import { cappedBudgetProgress, pacingStatusBadgeVariant } from '../types.js';
|
|
7
|
+
|
|
8
|
+
export interface Props {
|
|
9
|
+
pacing?: BudgetPacingView | null;
|
|
10
|
+
locale?: string;
|
|
11
|
+
label?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let { pacing = null, locale, label = 'Budget pacing' }: Props = $props();
|
|
15
|
+
const progress = $derived(pacing ? cappedBudgetProgress(pacing) : 0);
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
{#if !pacing}
|
|
19
|
+
<p class="empty">No budget pacing is available.</p>
|
|
20
|
+
{:else}
|
|
21
|
+
<Card padding="sm" variant="outlined">
|
|
22
|
+
<section class="budget-pacing">
|
|
23
|
+
<header>
|
|
24
|
+
<h3>{label}</h3>
|
|
25
|
+
<Badge variant={pacingStatusBadgeVariant(pacing.status)} size="sm">
|
|
26
|
+
{humanizeKey(pacing.status)}
|
|
27
|
+
</Badge>
|
|
28
|
+
</header>
|
|
29
|
+
|
|
30
|
+
<div class="progress-label">
|
|
31
|
+
<span>{formatCents(pacing.spendCents, pacing.currency, locale)} spent</span>
|
|
32
|
+
<span>
|
|
33
|
+
{pacing.budgetFraction === null
|
|
34
|
+
? 'No budget'
|
|
35
|
+
: formatPercent(pacing.budgetFraction, locale)}
|
|
36
|
+
</span>
|
|
37
|
+
</div>
|
|
38
|
+
<Progress value={progress} max={1} label={label} />
|
|
39
|
+
|
|
40
|
+
<dl class="facts">
|
|
41
|
+
<div>
|
|
42
|
+
<dt>Budget</dt>
|
|
43
|
+
<dd>{formatCents(pacing.budgetCents, pacing.currency, locale)}</dd>
|
|
44
|
+
</div>
|
|
45
|
+
<div>
|
|
46
|
+
<dt>Remaining</dt>
|
|
47
|
+
<dd class:negative={pacing.remainingCents < 0}>
|
|
48
|
+
{formatCents(pacing.remainingCents, pacing.currency, locale)}
|
|
49
|
+
</dd>
|
|
50
|
+
</div>
|
|
51
|
+
<div>
|
|
52
|
+
<dt>Expected by now</dt>
|
|
53
|
+
<dd>
|
|
54
|
+
{pacing.expectedSpendCents === null
|
|
55
|
+
? '—'
|
|
56
|
+
: formatCents(pacing.expectedSpendCents, pacing.currency, locale)}
|
|
57
|
+
</dd>
|
|
58
|
+
</div>
|
|
59
|
+
<div>
|
|
60
|
+
<dt>Variance</dt>
|
|
61
|
+
<dd class:negative={(pacing.varianceCents ?? 0) > 0}>
|
|
62
|
+
{pacing.varianceCents === null
|
|
63
|
+
? '—'
|
|
64
|
+
: formatCents(pacing.varianceCents, pacing.currency, locale)}
|
|
65
|
+
</dd>
|
|
66
|
+
</div>
|
|
67
|
+
</dl>
|
|
68
|
+
</section>
|
|
69
|
+
</Card>
|
|
70
|
+
{/if}
|
|
71
|
+
|
|
72
|
+
<style>
|
|
73
|
+
.budget-pacing {
|
|
74
|
+
display: flex;
|
|
75
|
+
flex-direction: column;
|
|
76
|
+
gap: var(--smrt-spacing-2);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
header,
|
|
80
|
+
.progress-label {
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
justify-content: space-between;
|
|
84
|
+
gap: var(--smrt-spacing-3);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
h3,
|
|
88
|
+
.empty {
|
|
89
|
+
margin: 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
h3 {
|
|
93
|
+
color: var(--smrt-color-on-surface);
|
|
94
|
+
font: var(--smrt-typography-title-medium-font);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.empty,
|
|
98
|
+
.progress-label,
|
|
99
|
+
dt {
|
|
100
|
+
color: var(--smrt-color-on-surface-variant);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.empty {
|
|
104
|
+
font: var(--smrt-typography-body-medium-font);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.progress-label,
|
|
108
|
+
dt {
|
|
109
|
+
font: var(--smrt-typography-body-small-font);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.facts {
|
|
113
|
+
display: grid;
|
|
114
|
+
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
|
115
|
+
gap: var(--smrt-spacing-3);
|
|
116
|
+
margin: var(--smrt-spacing-3) 0 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.facts dd {
|
|
120
|
+
margin: var(--smrt-spacing-1) 0 0;
|
|
121
|
+
color: var(--smrt-color-on-surface);
|
|
122
|
+
font: var(--smrt-typography-body-medium-font);
|
|
123
|
+
font-weight: var(--smrt-typography-weight-medium);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.facts dd.negative {
|
|
127
|
+
color: var(--smrt-color-error);
|
|
128
|
+
}
|
|
129
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { BudgetPacingView } from '../types.js';
|
|
2
|
+
export interface Props {
|
|
3
|
+
pacing?: BudgetPacingView | null;
|
|
4
|
+
locale?: string;
|
|
5
|
+
label?: string;
|
|
6
|
+
}
|
|
7
|
+
declare const BudgetPacing: import("svelte").Component<Props, {}, "">;
|
|
8
|
+
type BudgetPacing = ReturnType<typeof BudgetPacing>;
|
|
9
|
+
export default BudgetPacing;
|
|
10
|
+
//# sourceMappingURL=BudgetPacing.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"BudgetPacing.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/BudgetPacing.svelte.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAIpD,MAAM,WAAW,KAAK;IACpB,MAAM,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAuED,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Badge, Card } from '@happyvertical/smrt-ui/ui';
|
|
3
|
+
import type { Snippet } from 'svelte';
|
|
4
|
+
import {
|
|
5
|
+
formatCents,
|
|
6
|
+
formatDateRange,
|
|
7
|
+
formatNumber,
|
|
8
|
+
humanizeKey,
|
|
9
|
+
} from '../format.js';
|
|
10
|
+
import type { CampaignChannelView, CampaignDetailView } from '../types.js';
|
|
11
|
+
import { campaignStatusBadgeVariant } from '../types.js';
|
|
12
|
+
|
|
13
|
+
export interface Props {
|
|
14
|
+
campaign?: CampaignDetailView | null;
|
|
15
|
+
channels?: CampaignChannelView[];
|
|
16
|
+
locale?: string;
|
|
17
|
+
pacing?: Snippet;
|
|
18
|
+
channelMix?: Snippet;
|
|
19
|
+
children?: Snippet;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let {
|
|
23
|
+
campaign = null,
|
|
24
|
+
channels = [],
|
|
25
|
+
locale,
|
|
26
|
+
pacing,
|
|
27
|
+
channelMix,
|
|
28
|
+
children,
|
|
29
|
+
}: Props = $props();
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
{#if !campaign}
|
|
33
|
+
<p class="empty">Select a campaign to see its details.</p>
|
|
34
|
+
{:else}
|
|
35
|
+
<article class="campaign-detail">
|
|
36
|
+
<Card variant="outlined">
|
|
37
|
+
<header class="header">
|
|
38
|
+
<div>
|
|
39
|
+
<p class="eyebrow">{campaign.campaignKey}</p>
|
|
40
|
+
<h2>{campaign.name}</h2>
|
|
41
|
+
{#if campaign.description}<p class="description">{campaign.description}</p>{/if}
|
|
42
|
+
</div>
|
|
43
|
+
<Badge variant={campaignStatusBadgeVariant(campaign.status)}>
|
|
44
|
+
{humanizeKey(campaign.status)}
|
|
45
|
+
</Badge>
|
|
46
|
+
</header>
|
|
47
|
+
|
|
48
|
+
<dl class="facts">
|
|
49
|
+
<div>
|
|
50
|
+
<dt>Objective</dt>
|
|
51
|
+
<dd>{campaign.objective ? humanizeKey(campaign.objective) : '—'}</dd>
|
|
52
|
+
</div>
|
|
53
|
+
<div>
|
|
54
|
+
<dt>Schedule</dt>
|
|
55
|
+
<dd>{formatDateRange(campaign.startAt, campaign.endAt, locale)}</dd>
|
|
56
|
+
</div>
|
|
57
|
+
<div>
|
|
58
|
+
<dt>Budget</dt>
|
|
59
|
+
<dd>{formatCents(campaign.budgetCents, campaign.currency, locale)}</dd>
|
|
60
|
+
</div>
|
|
61
|
+
<div>
|
|
62
|
+
<dt>Spend</dt>
|
|
63
|
+
<dd>{formatCents(campaign.spendCents ?? 0, campaign.currency, locale)}</dd>
|
|
64
|
+
</div>
|
|
65
|
+
<div>
|
|
66
|
+
<dt>Impressions</dt>
|
|
67
|
+
<dd>{formatNumber(campaign.impressions ?? 0, locale)}</dd>
|
|
68
|
+
</div>
|
|
69
|
+
<div>
|
|
70
|
+
<dt>Leads</dt>
|
|
71
|
+
<dd>{formatNumber(campaign.leads ?? 0, locale)}</dd>
|
|
72
|
+
</div>
|
|
73
|
+
</dl>
|
|
74
|
+
</Card>
|
|
75
|
+
|
|
76
|
+
{#if channels.length > 0}
|
|
77
|
+
<Card padding="sm">
|
|
78
|
+
<section class="channels" aria-label="Campaign channels">
|
|
79
|
+
<h3>Executions</h3>
|
|
80
|
+
<ul>
|
|
81
|
+
{#each channels as channel (channel.id)}
|
|
82
|
+
<li>
|
|
83
|
+
<span>
|
|
84
|
+
<span class="channel-name">{channel.label ?? humanizeKey(channel.channelKind)}</span>
|
|
85
|
+
<span class="channel-ref">{channel.channelRef}</span>
|
|
86
|
+
</span>
|
|
87
|
+
<Badge variant={campaignStatusBadgeVariant(channel.status)} size="sm">
|
|
88
|
+
{humanizeKey(channel.status)}
|
|
89
|
+
</Badge>
|
|
90
|
+
</li>
|
|
91
|
+
{/each}
|
|
92
|
+
</ul>
|
|
93
|
+
</section>
|
|
94
|
+
</Card>
|
|
95
|
+
{/if}
|
|
96
|
+
|
|
97
|
+
{#if pacing}<section aria-label="Budget pacing">{@render pacing()}</section>{/if}
|
|
98
|
+
{#if channelMix}<section aria-label="Channel mix">{@render channelMix()}</section>{/if}
|
|
99
|
+
{@render children?.()}
|
|
100
|
+
</article>
|
|
101
|
+
{/if}
|
|
102
|
+
|
|
103
|
+
<style>
|
|
104
|
+
.campaign-detail {
|
|
105
|
+
display: flex;
|
|
106
|
+
flex-direction: column;
|
|
107
|
+
gap: var(--smrt-spacing-4);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.empty,
|
|
111
|
+
.description,
|
|
112
|
+
.channel-ref {
|
|
113
|
+
color: var(--smrt-color-on-surface-variant);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.empty,
|
|
117
|
+
.description {
|
|
118
|
+
margin: 0;
|
|
119
|
+
font: var(--smrt-typography-body-medium-font);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.header {
|
|
123
|
+
display: flex;
|
|
124
|
+
align-items: flex-start;
|
|
125
|
+
justify-content: space-between;
|
|
126
|
+
gap: var(--smrt-spacing-4);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.eyebrow {
|
|
130
|
+
margin: 0;
|
|
131
|
+
color: var(--smrt-color-on-surface-variant);
|
|
132
|
+
font: var(--smrt-typography-label-medium-font);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
h2,
|
|
136
|
+
h3 {
|
|
137
|
+
margin: 0;
|
|
138
|
+
color: var(--smrt-color-on-surface);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
h2 {
|
|
142
|
+
font: var(--smrt-typography-headline-small-font);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
h3 {
|
|
146
|
+
font: var(--smrt-typography-title-medium-font);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.description {
|
|
150
|
+
margin-top: var(--smrt-spacing-2);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.facts {
|
|
154
|
+
display: grid;
|
|
155
|
+
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
|
|
156
|
+
gap: var(--smrt-spacing-4);
|
|
157
|
+
margin: var(--smrt-spacing-5) 0 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.facts dt {
|
|
161
|
+
color: var(--smrt-color-on-surface-variant);
|
|
162
|
+
font: var(--smrt-typography-label-medium-font);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.facts dd {
|
|
166
|
+
margin: var(--smrt-spacing-1) 0 0;
|
|
167
|
+
color: var(--smrt-color-on-surface);
|
|
168
|
+
font: var(--smrt-typography-body-large-font);
|
|
169
|
+
font-weight: var(--smrt-typography-weight-medium);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.channels h3 {
|
|
173
|
+
margin-bottom: var(--smrt-spacing-3);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
ul {
|
|
177
|
+
display: flex;
|
|
178
|
+
flex-direction: column;
|
|
179
|
+
gap: var(--smrt-spacing-2);
|
|
180
|
+
margin: 0;
|
|
181
|
+
padding: 0;
|
|
182
|
+
list-style: none;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
li {
|
|
186
|
+
display: flex;
|
|
187
|
+
align-items: center;
|
|
188
|
+
justify-content: space-between;
|
|
189
|
+
gap: var(--smrt-spacing-3);
|
|
190
|
+
padding-bottom: var(--smrt-spacing-2);
|
|
191
|
+
border-bottom: 1px solid var(--smrt-color-outline-variant);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.channel-name,
|
|
195
|
+
.channel-ref {
|
|
196
|
+
display: block;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.channel-name {
|
|
200
|
+
color: var(--smrt-color-on-surface);
|
|
201
|
+
font: var(--smrt-typography-body-medium-font);
|
|
202
|
+
font-weight: var(--smrt-typography-weight-medium);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.channel-ref {
|
|
206
|
+
font: var(--smrt-typography-body-small-font);
|
|
207
|
+
}
|
|
208
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import type { CampaignChannelView, CampaignDetailView } from '../types.js';
|
|
3
|
+
export interface Props {
|
|
4
|
+
campaign?: CampaignDetailView | null;
|
|
5
|
+
channels?: CampaignChannelView[];
|
|
6
|
+
locale?: string;
|
|
7
|
+
pacing?: Snippet;
|
|
8
|
+
channelMix?: Snippet;
|
|
9
|
+
children?: Snippet;
|
|
10
|
+
}
|
|
11
|
+
declare const CampaignDetail: import("svelte").Component<Props, {}, "">;
|
|
12
|
+
type CampaignDetail = ReturnType<typeof CampaignDetail>;
|
|
13
|
+
export default CampaignDetail;
|
|
14
|
+
//# sourceMappingURL=CampaignDetail.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CampaignDetail.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/CampaignDetail.svelte.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AAOtC,OAAO,KAAK,EAAE,mBAAmB,EAAE,kBAAkB,EAAE,MAAM,aAAa,CAAC;AAI3E,MAAM,WAAW,KAAK;IACpB,QAAQ,CAAC,EAAE,kBAAkB,GAAG,IAAI,CAAC;IACrC,QAAQ,CAAC,EAAE,mBAAmB,EAAE,CAAC;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AA8FD,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
|