@actuate-media/cms-admin 0.56.2 → 0.57.1
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/CHANGELOG.md +15 -0
- package/dist/__tests__/views/ai-settings.render.test.js +38 -4
- package/dist/__tests__/views/ai-settings.render.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/AICoauthorBubbleMenu.d.ts.map +1 -1
- package/dist/components/AICoauthorBubbleMenu.js +5 -1
- package/dist/components/AICoauthorBubbleMenu.js.map +1 -1
- package/dist/components/BrandVoiceAlignmentBadge.d.ts +6 -0
- package/dist/components/BrandVoiceAlignmentBadge.d.ts.map +1 -0
- package/dist/components/BrandVoiceAlignmentBadge.js +11 -0
- package/dist/components/BrandVoiceAlignmentBadge.js.map +1 -0
- package/dist/components/CoauthorResultPopover.d.ts +5 -1
- package/dist/components/CoauthorResultPopover.d.ts.map +1 -1
- package/dist/components/CoauthorResultPopover.js +35 -6
- package/dist/components/CoauthorResultPopover.js.map +1 -1
- package/dist/components/CommandPalette.d.ts.map +1 -1
- package/dist/components/CommandPalette.js +5 -1
- package/dist/components/CommandPalette.js.map +1 -1
- package/dist/components/seo/SeoEditorDrawer.d.ts.map +1 -1
- package/dist/components/seo/SeoEditorDrawer.js +43 -2
- package/dist/components/seo/SeoEditorDrawer.js.map +1 -1
- package/dist/components/ui/Badge.d.ts +1 -1
- package/dist/lib/brand-voice-client.d.ts +21 -0
- package/dist/lib/brand-voice-client.d.ts.map +1 -0
- package/dist/lib/brand-voice-client.js +27 -0
- package/dist/lib/brand-voice-client.js.map +1 -0
- package/dist/lib/coauthor-client.d.ts +2 -0
- package/dist/lib/coauthor-client.d.ts.map +1 -1
- package/dist/lib/coauthor-client.js +1 -0
- package/dist/lib/coauthor-client.js.map +1 -1
- package/dist/lib/seo-service.d.ts +12 -0
- package/dist/lib/seo-service.d.ts.map +1 -1
- package/dist/lib/seo-service.js +0 -10
- package/dist/lib/seo-service.js.map +1 -1
- package/dist/views/Settings.js +1 -1
- package/dist/views/Settings.js.map +1 -1
- package/dist/views/settings/AISettingsTab.d.ts +3 -1
- package/dist/views/settings/AISettingsTab.d.ts.map +1 -1
- package/dist/views/settings/AISettingsTab.js +30 -2
- package/dist/views/settings/AISettingsTab.js.map +1 -1
- package/dist/views/settings/BrandVoiceCard.d.ts +44 -2
- package/dist/views/settings/BrandVoiceCard.d.ts.map +1 -1
- package/dist/views/settings/BrandVoiceCard.js +215 -8
- package/dist/views/settings/BrandVoiceCard.js.map +1 -1
- package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.d.ts +13 -0
- package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.d.ts.map +1 -0
- package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.js +74 -0
- package/dist/views/settings/brand-voice/BrandVoiceAlignmentPanel.js.map +1 -0
- package/dist/views/settings/brand-voice/BrandVoiceRulesSections.d.ts +7 -0
- package/dist/views/settings/brand-voice/BrandVoiceRulesSections.d.ts.map +1 -0
- package/dist/views/settings/brand-voice/BrandVoiceRulesSections.js +54 -0
- package/dist/views/settings/brand-voice/BrandVoiceRulesSections.js.map +1 -0
- package/dist/views/settings/useAiSettings.d.ts +32 -0
- package/dist/views/settings/useAiSettings.d.ts.map +1 -1
- package/dist/views/settings/useAiSettings.js +85 -3
- package/dist/views/settings/useAiSettings.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/views/ai-settings.render.test.tsx +54 -4
- package/src/components/AICoauthorBubbleMenu.tsx +12 -0
- package/src/components/BrandVoiceAlignmentBadge.tsx +39 -0
- package/src/components/CoauthorResultPopover.tsx +62 -4
- package/src/components/CommandPalette.tsx +12 -0
- package/src/components/seo/SeoEditorDrawer.tsx +65 -1
- package/src/lib/brand-voice-client.ts +38 -0
- package/src/lib/coauthor-client.ts +4 -0
- package/src/lib/seo-service.ts +2 -0
- package/src/views/Settings.tsx +1 -1
- package/src/views/settings/AISettingsTab.tsx +47 -3
- package/src/views/settings/BrandVoiceCard.tsx +608 -75
- package/src/views/settings/brand-voice/BrandVoiceAlignmentPanel.tsx +205 -0
- package/src/views/settings/brand-voice/BrandVoiceRulesSections.tsx +321 -0
- package/src/views/settings/useAiSettings.ts +134 -3
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { BrandAlignmentScoreResult } from '@actuate-media/cms-core/brand-voice'
|
|
4
|
+
import { BRAND_VOICE_CHANNEL_PRESETS } from '@actuate-media/cms-core/brand-voice'
|
|
5
|
+
import { Loader2, Wand2 } from 'lucide-react'
|
|
6
|
+
import { useId, useState } from 'react'
|
|
7
|
+
import { toast } from 'sonner'
|
|
8
|
+
import { fixBrandVoiceContent } from '../../../lib/brand-voice-client.js'
|
|
9
|
+
|
|
10
|
+
const INPUT_CLASS =
|
|
11
|
+
'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
|
|
12
|
+
const TEXTAREA_CLASS = `${INPUT_CLASS} min-h-[120px] resize-y`
|
|
13
|
+
const LABEL_CLASS = 'mb-1 block text-sm font-medium text-foreground'
|
|
14
|
+
|
|
15
|
+
function scoreTone(score: number): string {
|
|
16
|
+
if (score >= 85) return 'text-success'
|
|
17
|
+
if (score >= 65) return 'text-warning'
|
|
18
|
+
return 'text-destructive'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function BrandVoiceAlignmentPanel({
|
|
22
|
+
disabled,
|
|
23
|
+
onScore,
|
|
24
|
+
}: {
|
|
25
|
+
disabled: boolean
|
|
26
|
+
onScore: (input: { content: string; channel?: string }) => Promise<{
|
|
27
|
+
ok: boolean
|
|
28
|
+
result?: BrandAlignmentScoreResult
|
|
29
|
+
error?: string
|
|
30
|
+
}>
|
|
31
|
+
}) {
|
|
32
|
+
const contentId = useId()
|
|
33
|
+
const channelId = useId()
|
|
34
|
+
const [content, setContent] = useState('')
|
|
35
|
+
const [channel, setChannel] = useState('')
|
|
36
|
+
const [loading, setLoading] = useState(false)
|
|
37
|
+
const [fixing, setFixing] = useState(false)
|
|
38
|
+
const [result, setResult] = useState<BrandAlignmentScoreResult | null>(null)
|
|
39
|
+
const [error, setError] = useState<string | null>(null)
|
|
40
|
+
|
|
41
|
+
async function runScore() {
|
|
42
|
+
if (!content.trim()) return
|
|
43
|
+
setLoading(true)
|
|
44
|
+
setError(null)
|
|
45
|
+
try {
|
|
46
|
+
const res = await onScore({
|
|
47
|
+
content: content.trim(),
|
|
48
|
+
channel: channel || undefined,
|
|
49
|
+
})
|
|
50
|
+
if (!res.ok || !res.result) {
|
|
51
|
+
setResult(null)
|
|
52
|
+
setError(res.error ?? 'Could not score this copy.')
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
setResult(res.result)
|
|
56
|
+
} finally {
|
|
57
|
+
setLoading(false)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function runFix() {
|
|
62
|
+
if (!content.trim()) return
|
|
63
|
+
setFixing(true)
|
|
64
|
+
setError(null)
|
|
65
|
+
try {
|
|
66
|
+
const res = await fixBrandVoiceContent({
|
|
67
|
+
content: content.trim(),
|
|
68
|
+
channel: channel || undefined,
|
|
69
|
+
})
|
|
70
|
+
if (!res.ok || !res.result) {
|
|
71
|
+
setError(res.error ?? 'Could not fix this copy.')
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
setContent(res.result.text)
|
|
75
|
+
setResult(res.result.afterScore)
|
|
76
|
+
toast.success(
|
|
77
|
+
res.result.usedAi
|
|
78
|
+
? 'Copy revised to match brand voice.'
|
|
79
|
+
: 'Applied brand voice rule fixes.',
|
|
80
|
+
)
|
|
81
|
+
} finally {
|
|
82
|
+
setFixing(false)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<div className="border-border space-y-4 rounded-md border border-dashed p-4">
|
|
88
|
+
<div>
|
|
89
|
+
<h4 className="text-foreground text-base font-medium">Check alignment</h4>
|
|
90
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
91
|
+
Paste draft copy to score it against your vocabulary rules, mechanics, and channel limits.
|
|
92
|
+
</p>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div className="grid gap-4 md:grid-cols-[1fr_220px]">
|
|
96
|
+
<div>
|
|
97
|
+
<label htmlFor={contentId} className={LABEL_CLASS}>
|
|
98
|
+
Draft copy
|
|
99
|
+
</label>
|
|
100
|
+
<textarea
|
|
101
|
+
id={contentId}
|
|
102
|
+
value={content}
|
|
103
|
+
disabled={disabled || loading}
|
|
104
|
+
placeholder="Paste a headline, meta description, or paragraph to score."
|
|
105
|
+
onChange={(e) => setContent(e.target.value)}
|
|
106
|
+
className={TEXTAREA_CLASS}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
<div>
|
|
110
|
+
<label htmlFor={channelId} className={LABEL_CLASS}>
|
|
111
|
+
Channel (optional)
|
|
112
|
+
</label>
|
|
113
|
+
<select
|
|
114
|
+
id={channelId}
|
|
115
|
+
value={channel}
|
|
116
|
+
disabled={disabled || loading}
|
|
117
|
+
onChange={(e) => setChannel(e.target.value)}
|
|
118
|
+
className={INPUT_CLASS}
|
|
119
|
+
>
|
|
120
|
+
<option value="">General</option>
|
|
121
|
+
{BRAND_VOICE_CHANNEL_PRESETS.map((preset) => (
|
|
122
|
+
<option key={preset.id} value={preset.id}>
|
|
123
|
+
{preset.label}
|
|
124
|
+
</option>
|
|
125
|
+
))}
|
|
126
|
+
</select>
|
|
127
|
+
<button
|
|
128
|
+
type="button"
|
|
129
|
+
onClick={runScore}
|
|
130
|
+
disabled={disabled || loading || !content.trim()}
|
|
131
|
+
className="bg-primary text-primary-foreground mt-3 inline-flex w-full items-center justify-center gap-2 rounded-md px-4 py-2 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
|
|
132
|
+
>
|
|
133
|
+
{loading && <Loader2 size={16} className="animate-spin" aria-hidden="true" />}
|
|
134
|
+
Score copy
|
|
135
|
+
</button>
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
{error && (
|
|
140
|
+
<div
|
|
141
|
+
role="alert"
|
|
142
|
+
className="border-destructive/30 bg-destructive/10 text-destructive rounded-md border p-3 text-sm"
|
|
143
|
+
>
|
|
144
|
+
{error}
|
|
145
|
+
</div>
|
|
146
|
+
)}
|
|
147
|
+
|
|
148
|
+
{result && (
|
|
149
|
+
<div className="space-y-3" aria-live="polite">
|
|
150
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
151
|
+
<p className={`text-2xl font-medium ${scoreTone(result.score)}`}>{result.score}/100</p>
|
|
152
|
+
<p className="text-muted-foreground text-sm">
|
|
153
|
+
{result.channel ? `Scored for ${result.channel.replace(/_/g, ' ')}` : 'General score'}
|
|
154
|
+
</p>
|
|
155
|
+
{result.score < 85 && (
|
|
156
|
+
<button
|
|
157
|
+
type="button"
|
|
158
|
+
onClick={runFix}
|
|
159
|
+
disabled={disabled || fixing || loading}
|
|
160
|
+
className="border-border bg-card text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm disabled:cursor-not-allowed disabled:opacity-50"
|
|
161
|
+
>
|
|
162
|
+
{fixing ? (
|
|
163
|
+
<Loader2 size={16} className="animate-spin" aria-hidden="true" />
|
|
164
|
+
) : (
|
|
165
|
+
<Wand2 size={16} aria-hidden="true" />
|
|
166
|
+
)}
|
|
167
|
+
Fix to match brand voice
|
|
168
|
+
</button>
|
|
169
|
+
)}
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
{result.issues.length > 0 && (
|
|
173
|
+
<ul className="space-y-2">
|
|
174
|
+
{result.issues.map((issue) => (
|
|
175
|
+
<li
|
|
176
|
+
key={issue.id}
|
|
177
|
+
className="border-border bg-muted/30 rounded-md border px-3 py-2 text-sm"
|
|
178
|
+
>
|
|
179
|
+
<p className="text-foreground">{issue.message}</p>
|
|
180
|
+
{issue.suggestion && (
|
|
181
|
+
<p className="text-muted-foreground mt-1">{issue.suggestion}</p>
|
|
182
|
+
)}
|
|
183
|
+
</li>
|
|
184
|
+
))}
|
|
185
|
+
</ul>
|
|
186
|
+
)}
|
|
187
|
+
|
|
188
|
+
{result.dimensions.length > 0 && (
|
|
189
|
+
<div className="grid gap-2 sm:grid-cols-2">
|
|
190
|
+
{result.dimensions.map((dimension) => (
|
|
191
|
+
<div key={dimension.name} className="border-border rounded-md border px-3 py-2">
|
|
192
|
+
<div className="flex items-center justify-between gap-2">
|
|
193
|
+
<p className="text-foreground text-sm font-medium">{dimension.name}</p>
|
|
194
|
+
<p className={`text-sm ${scoreTone(dimension.score)}`}>{dimension.score}</p>
|
|
195
|
+
</div>
|
|
196
|
+
<p className="text-muted-foreground mt-1 text-sm">{dimension.feedback}</p>
|
|
197
|
+
</div>
|
|
198
|
+
))}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
)
|
|
205
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
BrandVoiceBannedTerm,
|
|
5
|
+
BrandVoiceChannelRule,
|
|
6
|
+
BrandVoiceStructuredProfile,
|
|
7
|
+
BrandVoiceWorkedExample,
|
|
8
|
+
} from '@actuate-media/cms-core/brand-voice'
|
|
9
|
+
import {
|
|
10
|
+
MAX_BANNED_TERMS,
|
|
11
|
+
MAX_PREFERRED_TERMS,
|
|
12
|
+
MAX_WORKED_EXAMPLES,
|
|
13
|
+
} from '@actuate-media/cms-core/brand-voice'
|
|
14
|
+
import { Plus, Trash2 } from 'lucide-react'
|
|
15
|
+
import { useId } from 'react'
|
|
16
|
+
|
|
17
|
+
const INPUT_CLASS =
|
|
18
|
+
'w-full rounded-md border border-border bg-input-background px-3 py-2 text-base text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-60'
|
|
19
|
+
const LABEL_CLASS = 'mb-1 block text-sm font-medium text-foreground'
|
|
20
|
+
|
|
21
|
+
export function BrandVoiceRulesSections({
|
|
22
|
+
structured,
|
|
23
|
+
disabled,
|
|
24
|
+
onChange,
|
|
25
|
+
}: {
|
|
26
|
+
structured: BrandVoiceStructuredProfile
|
|
27
|
+
disabled: boolean
|
|
28
|
+
onChange: (next: BrandVoiceStructuredProfile) => void
|
|
29
|
+
}) {
|
|
30
|
+
const preferredId = useId()
|
|
31
|
+
const patternId = useId()
|
|
32
|
+
|
|
33
|
+
function update(patch: Partial<BrandVoiceStructuredProfile>) {
|
|
34
|
+
onChange({ ...structured, ...patch })
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function updateBanned(index: number, patch: Partial<BrandVoiceBannedTerm>) {
|
|
38
|
+
const bannedTerms = structured.bannedTerms.map((row, i) =>
|
|
39
|
+
i === index ? { ...row, ...patch } : row,
|
|
40
|
+
)
|
|
41
|
+
update({ bannedTerms })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function updateChannel(index: number, patch: Partial<BrandVoiceChannelRule>) {
|
|
45
|
+
const channels = structured.channels.map((row, i) => (i === index ? { ...row, ...patch } : row))
|
|
46
|
+
update({ channels })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function updateExample(index: number, patch: Partial<BrandVoiceWorkedExample>) {
|
|
50
|
+
const workedExamples = structured.workedExamples.map((row, i) =>
|
|
51
|
+
i === index ? { ...row, ...patch } : row,
|
|
52
|
+
)
|
|
53
|
+
update({ workedExamples })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="space-y-6">
|
|
58
|
+
<section className="space-y-3">
|
|
59
|
+
<div>
|
|
60
|
+
<h4 className="text-foreground text-base font-medium">Vocabulary rules</h4>
|
|
61
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
62
|
+
Banned terms produce deterministic alignment errors. Preferred terms are scored as
|
|
63
|
+
bonuses.
|
|
64
|
+
</p>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<div className="space-y-2">
|
|
68
|
+
<p className="text-foreground text-sm font-medium">Banned terms</p>
|
|
69
|
+
{structured.bannedTerms.length === 0 && (
|
|
70
|
+
<p className="text-muted-foreground text-sm">No banned terms yet.</p>
|
|
71
|
+
)}
|
|
72
|
+
{structured.bannedTerms.map((row, index) => (
|
|
73
|
+
<div key={`banned-${index}`} className="grid gap-2 md:grid-cols-[1fr_1fr_auto]">
|
|
74
|
+
<input
|
|
75
|
+
type="text"
|
|
76
|
+
value={row.term}
|
|
77
|
+
disabled={disabled}
|
|
78
|
+
placeholder="e.g. cheap"
|
|
79
|
+
aria-label={`Banned term ${index + 1}`}
|
|
80
|
+
onChange={(e) => updateBanned(index, { term: e.target.value })}
|
|
81
|
+
className={INPUT_CLASS}
|
|
82
|
+
/>
|
|
83
|
+
<input
|
|
84
|
+
type="text"
|
|
85
|
+
value={row.useInstead}
|
|
86
|
+
disabled={disabled}
|
|
87
|
+
placeholder="Use instead (optional)"
|
|
88
|
+
aria-label={`Replacement for banned term ${index + 1}`}
|
|
89
|
+
onChange={(e) => updateBanned(index, { useInstead: e.target.value })}
|
|
90
|
+
className={INPUT_CLASS}
|
|
91
|
+
/>
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
disabled={disabled}
|
|
95
|
+
aria-label={`Remove banned term ${index + 1}`}
|
|
96
|
+
onClick={() =>
|
|
97
|
+
update({ bannedTerms: structured.bannedTerms.filter((_, i) => i !== index) })
|
|
98
|
+
}
|
|
99
|
+
className="border-border text-muted-foreground hover:bg-accent inline-flex items-center justify-center rounded-md border px-3 py-2 transition-colors disabled:opacity-50"
|
|
100
|
+
>
|
|
101
|
+
<Trash2 size={16} aria-hidden="true" />
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
))}
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
disabled={disabled || structured.bannedTerms.length >= MAX_BANNED_TERMS}
|
|
108
|
+
onClick={() =>
|
|
109
|
+
update({ bannedTerms: [...structured.bannedTerms, { term: '', useInstead: '' }] })
|
|
110
|
+
}
|
|
111
|
+
className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:opacity-50"
|
|
112
|
+
>
|
|
113
|
+
<Plus size={14} aria-hidden="true" />
|
|
114
|
+
Add banned term
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
<div>
|
|
119
|
+
<label htmlFor={preferredId} className={LABEL_CLASS}>
|
|
120
|
+
Preferred terms
|
|
121
|
+
</label>
|
|
122
|
+
<input
|
|
123
|
+
id={preferredId}
|
|
124
|
+
type="text"
|
|
125
|
+
disabled={disabled}
|
|
126
|
+
placeholder="Comma-separated, e.g. ecommerce, sign up"
|
|
127
|
+
value={structured.preferredTerms.join(', ')}
|
|
128
|
+
onChange={(e) =>
|
|
129
|
+
update({
|
|
130
|
+
preferredTerms: e.target.value
|
|
131
|
+
.split(',')
|
|
132
|
+
.map((t) => t.trim())
|
|
133
|
+
.filter(Boolean)
|
|
134
|
+
.slice(0, MAX_PREFERRED_TERMS),
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
className={INPUT_CLASS}
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<div>
|
|
142
|
+
<label htmlFor={patternId} className={LABEL_CLASS}>
|
|
143
|
+
Avoid patterns
|
|
144
|
+
</label>
|
|
145
|
+
<textarea
|
|
146
|
+
id={patternId}
|
|
147
|
+
disabled={disabled}
|
|
148
|
+
placeholder={
|
|
149
|
+
'One per line, e.g.\nhype stacking (multiple superlatives in one sentence)'
|
|
150
|
+
}
|
|
151
|
+
value={structured.avoidPatterns.join('\n')}
|
|
152
|
+
onChange={(e) =>
|
|
153
|
+
update({
|
|
154
|
+
avoidPatterns: e.target.value
|
|
155
|
+
.split('\n')
|
|
156
|
+
.map((line) => line.trim())
|
|
157
|
+
.filter(Boolean),
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
className={`${INPUT_CLASS} min-h-[88px] resize-y`}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
</section>
|
|
164
|
+
|
|
165
|
+
<section className="space-y-3">
|
|
166
|
+
<div>
|
|
167
|
+
<h4 className="text-foreground text-base font-medium">Channel overrides</h4>
|
|
168
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
169
|
+
Length limits and notes used when scoring copy for a specific channel.
|
|
170
|
+
</p>
|
|
171
|
+
</div>
|
|
172
|
+
<div className="space-y-3">
|
|
173
|
+
{structured.channels.map((channel, index) => (
|
|
174
|
+
<div key={channel.id} className="border-border rounded-md border p-4">
|
|
175
|
+
<p className="text-foreground mb-3 text-sm font-medium">{channel.label}</p>
|
|
176
|
+
<div className="grid gap-3 md:grid-cols-3">
|
|
177
|
+
{channel.id === 'google_ads' ? (
|
|
178
|
+
<>
|
|
179
|
+
<div>
|
|
180
|
+
<label className={LABEL_CLASS}>Max headline chars</label>
|
|
181
|
+
<input
|
|
182
|
+
type="number"
|
|
183
|
+
min={1}
|
|
184
|
+
disabled={disabled}
|
|
185
|
+
value={channel.maxHeadlineChars ?? ''}
|
|
186
|
+
onChange={(e) =>
|
|
187
|
+
updateChannel(index, {
|
|
188
|
+
maxHeadlineChars: e.target.value ? Number(e.target.value) : undefined,
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
className={INPUT_CLASS}
|
|
192
|
+
/>
|
|
193
|
+
</div>
|
|
194
|
+
<div>
|
|
195
|
+
<label className={LABEL_CLASS}>Max description chars</label>
|
|
196
|
+
<input
|
|
197
|
+
type="number"
|
|
198
|
+
min={1}
|
|
199
|
+
disabled={disabled}
|
|
200
|
+
value={channel.maxDescriptionChars ?? ''}
|
|
201
|
+
onChange={(e) =>
|
|
202
|
+
updateChannel(index, {
|
|
203
|
+
maxDescriptionChars: e.target.value
|
|
204
|
+
? Number(e.target.value)
|
|
205
|
+
: undefined,
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
className={INPUT_CLASS}
|
|
209
|
+
/>
|
|
210
|
+
</div>
|
|
211
|
+
</>
|
|
212
|
+
) : (
|
|
213
|
+
<div>
|
|
214
|
+
<label className={LABEL_CLASS}>Max characters</label>
|
|
215
|
+
<input
|
|
216
|
+
type="number"
|
|
217
|
+
min={1}
|
|
218
|
+
disabled={disabled}
|
|
219
|
+
value={channel.maxChars ?? ''}
|
|
220
|
+
onChange={(e) =>
|
|
221
|
+
updateChannel(index, {
|
|
222
|
+
maxChars: e.target.value ? Number(e.target.value) : undefined,
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
className={INPUT_CLASS}
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
<div className={channel.id === 'google_ads' ? 'md:col-span-1' : 'md:col-span-2'}>
|
|
230
|
+
<label className={LABEL_CLASS}>Note</label>
|
|
231
|
+
<input
|
|
232
|
+
type="text"
|
|
233
|
+
disabled={disabled}
|
|
234
|
+
value={channel.note ?? ''}
|
|
235
|
+
onChange={(e) => updateChannel(index, { note: e.target.value })}
|
|
236
|
+
className={INPUT_CLASS}
|
|
237
|
+
/>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
))}
|
|
242
|
+
</div>
|
|
243
|
+
</section>
|
|
244
|
+
|
|
245
|
+
<section className="space-y-3">
|
|
246
|
+
<div>
|
|
247
|
+
<h4 className="text-foreground text-base font-medium">Worked examples</h4>
|
|
248
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
249
|
+
Off-brand vs on-brand pairs anchor scoring feedback and the sample copy field.
|
|
250
|
+
</p>
|
|
251
|
+
</div>
|
|
252
|
+
{structured.workedExamples.length === 0 && (
|
|
253
|
+
<p className="text-muted-foreground text-sm">No worked examples yet.</p>
|
|
254
|
+
)}
|
|
255
|
+
{structured.workedExamples.map((example, index) => (
|
|
256
|
+
<div key={`example-${index}`} className="border-border space-y-2 rounded-md border p-4">
|
|
257
|
+
<div className="flex items-center justify-between gap-3">
|
|
258
|
+
<p className="text-foreground text-sm font-medium">Example {index + 1}</p>
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
disabled={disabled}
|
|
262
|
+
aria-label={`Remove worked example ${index + 1}`}
|
|
263
|
+
onClick={() =>
|
|
264
|
+
update({
|
|
265
|
+
workedExamples: structured.workedExamples.filter((_, i) => i !== index),
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
className="border-border text-muted-foreground hover:bg-accent inline-flex items-center justify-center rounded-md border px-2 py-1 transition-colors disabled:opacity-50"
|
|
269
|
+
>
|
|
270
|
+
<Trash2 size={14} aria-hidden="true" />
|
|
271
|
+
</button>
|
|
272
|
+
</div>
|
|
273
|
+
<input
|
|
274
|
+
type="text"
|
|
275
|
+
disabled={disabled}
|
|
276
|
+
placeholder="Off-brand copy"
|
|
277
|
+
aria-label={`Off-brand example ${index + 1}`}
|
|
278
|
+
value={example.offBrand}
|
|
279
|
+
onChange={(e) => updateExample(index, { offBrand: e.target.value })}
|
|
280
|
+
className={INPUT_CLASS}
|
|
281
|
+
/>
|
|
282
|
+
<input
|
|
283
|
+
type="text"
|
|
284
|
+
disabled={disabled}
|
|
285
|
+
placeholder="On-brand rewrite"
|
|
286
|
+
aria-label={`On-brand example ${index + 1}`}
|
|
287
|
+
value={example.onBrand}
|
|
288
|
+
onChange={(e) => updateExample(index, { onBrand: e.target.value })}
|
|
289
|
+
className={INPUT_CLASS}
|
|
290
|
+
/>
|
|
291
|
+
<input
|
|
292
|
+
type="text"
|
|
293
|
+
disabled={disabled}
|
|
294
|
+
placeholder="Why this is better"
|
|
295
|
+
aria-label={`Why example ${index + 1} is on-brand`}
|
|
296
|
+
value={example.why}
|
|
297
|
+
onChange={(e) => updateExample(index, { why: e.target.value })}
|
|
298
|
+
className={INPUT_CLASS}
|
|
299
|
+
/>
|
|
300
|
+
</div>
|
|
301
|
+
))}
|
|
302
|
+
<button
|
|
303
|
+
type="button"
|
|
304
|
+
disabled={disabled || structured.workedExamples.length >= MAX_WORKED_EXAMPLES}
|
|
305
|
+
onClick={() =>
|
|
306
|
+
update({
|
|
307
|
+
workedExamples: [
|
|
308
|
+
...structured.workedExamples,
|
|
309
|
+
{ offBrand: '', onBrand: '', why: '' },
|
|
310
|
+
],
|
|
311
|
+
})
|
|
312
|
+
}
|
|
313
|
+
className="border-border text-foreground hover:bg-accent inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-sm transition-colors disabled:opacity-50"
|
|
314
|
+
>
|
|
315
|
+
<Plus size={14} aria-hidden="true" />
|
|
316
|
+
Add worked example
|
|
317
|
+
</button>
|
|
318
|
+
</section>
|
|
319
|
+
</div>
|
|
320
|
+
)
|
|
321
|
+
}
|