Skip to content

Commit 230477a

Browse files
committed
feat: add marketing skill blog generation and landing learning loop
1 parent 77c783f commit 230477a

21 files changed

Lines changed: 1005 additions & 56 deletions

File tree

frontend/src/api/blogGen.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import { apiJson } from "./client";
2-
import type { BlogDraft, BlogStyle } from "../types";
2+
import type { BlogDraft, BlogStyle, MarketingSkillId } from "../types";
33

44
export interface BlogGenerateParams {
55
style: BlogStyle;
6+
skill_id: MarketingSkillId;
67
bilingual: boolean;
78
}
89

910
export interface BlogGenerateResult {
1011
task_id: string;
1112
project_id: number;
1213
style: string;
14+
skill_id: MarketingSkillId;
15+
skill_name: string;
1316
status: string;
1417
}
1518

frontend/src/components/marketing/PublicSiteHeader.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ExternalLink, Sparkles } from "lucide-react";
2+
import type { MouseEvent } from "react";
23
import { Link, useLocation, useNavigate } from "react-router";
34
import { useI18n } from "../../i18n";
45
import { LOCALE_LABELS, SUPPORTED_LOCALES, type Locale } from "../../i18n/locale";
@@ -23,8 +24,17 @@ function PublicNavLink({
2324
className,
2425
}: { href: string; label: string; className: string }) {
2526
if (href.startsWith("#")) {
27+
const scrollToSection = (event: MouseEvent<HTMLAnchorElement>) => {
28+
const target = document.getElementById(href.slice(1));
29+
if (!target) return;
30+
31+
event.preventDefault();
32+
target.scrollIntoView({ behavior: "smooth", block: "start" });
33+
window.history.replaceState(null, "", href);
34+
};
35+
2636
return (
27-
<a href={href} className={className}>
37+
<a href={href} className={className} onClick={scrollToSection}>
2838
{label}
2939
</a>
3040
);

frontend/src/components/project/BlogGenerateButton.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useBlogGenerate } from "../../hooks/useBlogGen";
44
import { useTaskPoll } from "../../hooks/useTasks";
55
import { useQueryClient } from "@tanstack/react-query";
66
import { useI18n } from "../../i18n";
7-
import type { BlogStyle } from "../../types";
7+
import type { BlogStyle, MarketingSkillId } from "../../types";
88
import type { TranslationKey } from "../../i18n";
99

1010
const STYLES: { value: BlogStyle; labelKey: TranslationKey; descKey: TranslationKey }[] = [
@@ -14,6 +14,15 @@ const STYLES: { value: BlogStyle; labelKey: TranslationKey; descKey: Translation
1414
{ value: "thought_leadership", labelKey: "blogGen.style.thought_leadership", descKey: "blogGen.style.thought_leadershipDesc" },
1515
];
1616

17+
const MARKETING_SKILLS: { value: MarketingSkillId; labelKey: TranslationKey; descKey: TranslationKey }[] = [
18+
{ value: "content_strategy", labelKey: "blogGen.skill.content_strategy", descKey: "blogGen.skill.content_strategyDesc" },
19+
{ value: "copywriting", labelKey: "blogGen.skill.copywriting", descKey: "blogGen.skill.copywritingDesc" },
20+
{ value: "ai_seo", labelKey: "blogGen.skill.ai_seo", descKey: "blogGen.skill.ai_seoDesc" },
21+
{ value: "competitor_alternatives", labelKey: "blogGen.skill.competitor_alternatives", descKey: "blogGen.skill.competitor_alternativesDesc" },
22+
{ value: "programmatic_seo", labelKey: "blogGen.skill.programmatic_seo", descKey: "blogGen.skill.programmatic_seoDesc" },
23+
{ value: "directory_submissions", labelKey: "blogGen.skill.directory_submissions", descKey: "blogGen.skill.directory_submissionsDesc" },
24+
];
25+
1726
export function BlogGenerateButton({
1827
projectId,
1928
onViewDrafts,
@@ -24,6 +33,7 @@ export function BlogGenerateButton({
2433
const [taskId, setTaskId] = useState<string | null>(null);
2534
const [showConfig, setShowConfig] = useState(false);
2635
const [style, setStyle] = useState<BlogStyle>("launch");
36+
const [skillId, setSkillId] = useState<MarketingSkillId>("content_strategy");
2737
const [bilingual, setBilingual] = useState(false);
2838

2939
const blogGenerate = useBlogGenerate(projectId);
@@ -45,7 +55,7 @@ export function BlogGenerateButton({
4555
const handleGenerate = async () => {
4656
setShowConfig(false);
4757
try {
48-
const result = await blogGenerate.mutateAsync({ style, bilingual });
58+
const result = await blogGenerate.mutateAsync({ style, skill_id: skillId, bilingual });
4959
setTaskId(result.task_id);
5060
} catch {
5161
// mutation error handled by TanStack
@@ -147,6 +157,33 @@ export function BlogGenerateButton({
147157
))}
148158
</div>
149159

160+
<p className="mt-4 text-xs font-semibold text-slate-700">{t("blogGen.selectSkill")}</p>
161+
<div className="mt-2 grid gap-2 sm:grid-cols-2">
162+
{MARKETING_SKILLS.map((s) => (
163+
<label
164+
key={s.value}
165+
className={`flex cursor-pointer items-start gap-2 rounded-xl border px-3 py-2 transition-colors ${
166+
skillId === s.value
167+
? "border-emerald-300 bg-emerald-50/50"
168+
: "border-slate-200 hover:bg-slate-50"
169+
}`}
170+
>
171+
<input
172+
type="radio"
173+
name="marketingSkill"
174+
value={s.value}
175+
checked={skillId === s.value}
176+
onChange={() => setSkillId(s.value)}
177+
className="mt-0.5"
178+
/>
179+
<div className="min-w-0">
180+
<span className="text-xs font-medium text-slate-900">{t(s.labelKey)}</span>
181+
<p className="text-[11px] leading-4 text-slate-500">{t(s.descKey)}</p>
182+
</div>
183+
</label>
184+
))}
185+
</div>
186+
150187
<label className="mt-3 flex items-center gap-2">
151188
<input
152189
type="checkbox"

frontend/src/content/marketing.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export type BlogArticle = {
4040

4141
export const PUBLIC_HOME_NAV: PublicNavItem[] = [
4242
{ href: "#product", label: "landing.navPlatform" },
43+
{ href: "#learning", label: "landing.navLearning" },
4344
{ href: "#workflow", label: "landing.navWorkflow" },
4445
{ href: "#open-source", label: "landing.navOpenSource" },
4546
{ href: "#mentions", label: "landing.navMentions" },
@@ -74,6 +75,25 @@ export const LANDING_PLATFORM_ITEMS: MarketingItem[] = [
7475
},
7576
];
7677

78+
export const LANDING_LEARNING_LOOP_ITEMS: MarketingItem[] = [
79+
{
80+
title: "landing.learningLoop1Title",
81+
description: "landing.learningLoop1Desc",
82+
},
83+
{
84+
title: "landing.learningLoop2Title",
85+
description: "landing.learningLoop2Desc",
86+
},
87+
{
88+
title: "landing.learningLoop3Title",
89+
description: "landing.learningLoop3Desc",
90+
},
91+
{
92+
title: "landing.learningLoop4Title",
93+
description: "landing.learningLoop4Desc",
94+
},
95+
];
96+
7797
export const LANDING_WORKFLOW_STEPS: MarketingItem[] = [
7898
{
7999
title: "landing.stage1",

frontend/src/i18n/locales/en.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -404,19 +404,20 @@ export const en = {
404404
"onboarding.limitedModeWorks": "Crawl-based SEO checks, Google Search snapshots, and community discovery still run from the URL-first flow.",
405405
"onboarding.limitedModeNeedsKeyTitle": "Works better with a key",
406406
"onboarding.limitedModeNeedsKey": "AI-assisted keyword extraction, automatic competitor context, and broader AI Search checks need a configured provider.",
407-
"landing.headerTagline": "Search, AI visibility, and next actions.",
408-
"landing.metaTitle": "OpenCMO | Open-source AI CMO for developer products",
409-
"landing.metaDescription": "OpenCMO helps developer products understand how they show up in search, AI answers, and developer communities, then turns those signals into next actions.",
407+
"landing.headerTagline": "Visibility work that learns each loop.",
408+
"landing.metaTitle": "OpenCMO | Open-source AI CMO that learns from every scan",
409+
"landing.metaDescription": "OpenCMO is an open-source AI CMO for developer products, turning search, AI visibility, community signals, approvals, and follow-up scans into one compounding visibility system.",
410410
"landing.navOpenSource": "Open source",
411411
"landing.navPlatform": "First scan",
412+
"landing.navLearning": "Learning loop",
412413
"landing.navWorkflow": "Workflow",
413414
"landing.navBlog": "Blog",
414415
"landing.navTrust": "Trust",
415416
"landing.navMentions": "Mentions",
416417
"landing.navFaq": "FAQ",
417-
"landing.heroEyebrow": "Open-source AI CMO for developer products",
418-
"landing.heroTitle": "Run one scan to see how your product is understood across search, AI answers, and community.",
419-
"landing.heroSubtitle": "OpenCMO turns the first scan into one working brief: site-health blockers, AI-search gaps, community demand, and the next move to ship.",
418+
"landing.heroEyebrow": "Open-source AI CMO system",
419+
"landing.heroTitle": "A visibility operating system that learns from every scan.",
420+
"landing.heroSubtitle": "OpenCMO connects search, AI answers, community signals, approvals, and follow-up scans into one evidence trail, so each loop makes the next action sharper.",
420421
"landing.badge": "Built for open-source projects, developer tools, and technical products that need clear first-run answers.",
421422
"landing.title": "One open-source AI CMO workspace for search, AI answers, and developer communities.",
422423
"landing.subtitle": "OpenCMO helps you understand how your brand is discovered by Google, AI assistants, and real communities, then turns those signals into concrete growth actions.",
@@ -456,7 +457,7 @@ export const en = {
456457
"landing.stage6": "Persist and publish",
457458
"landing.platformEyebrow": "First scan output",
458459
"landing.platformTitle": "The first scan should not end as another dashboard.",
459-
"landing.platformSubtitle": "OpenCMO turns the public evidence into work your team can review, prioritize, and ship.",
460+
"landing.platformSubtitle": "OpenCMO turns public evidence into work your team can review, prioritize, ship, and measure again.",
460461
"landing.platform1Title": "Site health and SEO",
461462
"landing.platform1Desc": "Audit crawlability, Core Web Vitals, metadata, and technical issues before they quietly suppress discovery.",
462463
"landing.platform2Title": "AI Search visibility",
@@ -467,6 +468,34 @@ export const en = {
467468
"landing.platform4Desc": "Keep competitor context, topic gaps, and market structure close to the scan so strategy stays grounded.",
468469
"landing.platform5Title": "Reports, approvals, and leads",
469470
"landing.platform5Desc": "Move from findings into Review & Publish, reports, and developer leads without rebuilding the context in another tool.",
471+
"landing.learningEyebrow": "Learning loop",
472+
"landing.learningTitle": "The value compounds when evidence, decisions, and outcomes stay together.",
473+
"landing.learningSubtitle": "The strongest idea from autonomous marketing systems is not more tools. It is continuity. OpenCMO keeps each scan, review, approved action, and follow-up result attached to the same project context.",
474+
"landing.learningLoop1Title": "Observe the public surface",
475+
"landing.learningLoop1Desc": "Capture search, AI-answer, SERP, site-health, and community evidence before the team debates what to do.",
476+
"landing.learningLoop2Title": "Interpret the drift",
477+
"landing.learningLoop2Desc": "Compare the new readout with prior context so category mismatch, narrative gaps, and competitor pressure are easier to see.",
478+
"landing.learningLoop3Title": "Ship with guardrails",
479+
"landing.learningLoop3Desc": "Route recommendations into reports, approvals, content work, and outbound actions with the evidence still attached.",
480+
"landing.learningLoop4Title": "Feed the result back",
481+
"landing.learningLoop4Desc": "Use the next scan to see what changed, what held, and what should be adjusted instead of starting from zero.",
482+
"landing.learningMemoryEyebrow": "System memory",
483+
"landing.learningMemoryTitle": "Every run updates the next recommendation.",
484+
"landing.learningMemoryBody": "OpenCMO is shaped like a workspace because marketing judgment improves when signals, decisions, and shipped work are not scattered across separate tools.",
485+
"landing.learningMemoryRow1Label": "Baseline",
486+
"landing.learningMemoryRow1Value": "The first scan captures how the market currently reads the product.",
487+
"landing.learningMemoryRow2Label": "Decision",
488+
"landing.learningMemoryRow2Value": "The strongest findings move into review with source evidence and tradeoffs.",
489+
"landing.learningMemoryRow3Label": "Action",
490+
"landing.learningMemoryRow3Value": "Approved fixes, reports, replies, and content stay tied to the project.",
491+
"landing.learningMemoryRow4Label": "Next loop",
492+
"landing.learningMemoryRow4Value": "Follow-up scans compare the outcome and reset the next priority.",
493+
"landing.learningStat1Label": "Context",
494+
"landing.learningStat1Value": "Retained",
495+
"landing.learningStat2Label": "Decisions",
496+
"landing.learningStat2Value": "Traceable",
497+
"landing.learningStat3Label": "Actions",
498+
"landing.learningStat3Value": "Measurable",
470499
"landing.workflowEyebrow": "How it works",
471500
"landing.workflowTitle": "From public signals to the next move.",
472501
"landing.workflowSubtitle": "Under the hood is a six-stage pipeline. For the team, the promise is simpler: see the gap clearly, then ship the highest-leverage fix.",
@@ -1122,6 +1151,7 @@ export const en = {
11221151
"blogGen.regenerate": "Regenerate",
11231152
"blogGen.cancel": "Cancel",
11241153
"blogGen.selectStyle": "Select blog style",
1154+
"blogGen.selectSkill": "Select marketing framework",
11251155
"blogGen.bilingual": "Generate bilingual (EN + ZH)",
11261156
"blogGen.style.launch": "Launch Announcement",
11271157
"blogGen.style.launchDesc": "Product launch news with what/why/when structure",
@@ -1131,6 +1161,18 @@ export const en = {
11311161
"blogGen.style.comparisonDesc": "Feature-by-feature competitive comparison",
11321162
"blogGen.style.thought_leadership": "Thought Leadership",
11331163
"blogGen.style.thought_leadershipDesc": "Industry insight with product as part of the solution",
1164+
"blogGen.skill.content_strategy": "Content Strategy",
1165+
"blogGen.skill.content_strategyDesc": "Topic cluster, buyer stage, and internal-link strategy.",
1166+
"blogGen.skill.copywriting": "Conversion Copy",
1167+
"blogGen.skill.copywritingDesc": "Sharper positioning, benefits, objections, and CTA.",
1168+
"blogGen.skill.ai_seo": "AI SEO",
1169+
"blogGen.skill.ai_seoDesc": "Answer-engine structure, citations, FAQs, and schema notes.",
1170+
"blogGen.skill.competitor_alternatives": "Alternatives",
1171+
"blogGen.skill.competitor_alternativesDesc": "Fair comparison pages for competitor-intent searches.",
1172+
"blogGen.skill.programmatic_seo": "Programmatic SEO",
1173+
"blogGen.skill.programmatic_seoDesc": "Repeatable page patterns, data fields, and quality checks.",
1174+
"blogGen.skill.directory_submissions": "Directories",
1175+
"blogGen.skill.directory_submissionsDesc": "Readiness, assets, positioning variants, and tracker fields.",
11341176
"blogGen.completed": "Blog generated successfully",
11351177
"blogGen.failed": "Generation failed",
11361178
"blogGen.retry": "Retry",
@@ -1139,6 +1181,7 @@ export const en = {
11391181
"blogGen.scores.readability": "Readability",
11401182
"blogGen.scores.keywords": "Keywords",
11411183
"blogGen.scores.structure": "Structure",
1184+
"blogGen.scores.framework": "Framework",
11421185
"project.content": "Content",
11431186
"content.draftsTitle": "Generated drafts",
11441187
"content.draftsSubtitle": "Blog drafts generated by the promotional blog pipeline. Approve them in the Review queue.",

0 commit comments

Comments
 (0)