Skip to content

Commit 75484f8

Browse files
committed
Fix launch-wizard 'queued' bug + add currency selector + audit pre-fill/save
Three concrete fixes from real-user feedback: 1. Wizard phases showed "queued" with no output after status went "done" - res.text returned empty from some streaming responses (notably Gemini when content arrived purely via deltas), and the done-state update was overwriting the accumulated streamed text with the empty res.text. - Fix in app/launch/wizard/page.tsx: finalText = res.text || accumulated in both the primary and fallback-provider paths. Same fix applied to GeneratorShell so auto-saved ads never contain empty output_text. 2. Currency support (INR + 11 others) - New lib/currency.ts: 12 currencies (USD/INR/EUR/GBP/CAD/AUD/AED/SGD/ JPY/MXN/BRL/ZAR) with symbols and rough USD conversion rates. getCurrencyCode/setCurrencyCode/getCurrency/formatMoney/parseMoneyInput. - Settings page gains a Currency dropdown with symbol+code+label per option. - StatusBar / cost displays / brand-onboarding cost preview route through formatCost → formatMoney → user's chosen currency (USD pricing converted for display only — explicit not-for-accounting note). - Launch wizard budget input shows the active currency code in the label and symbol in the placeholder. Budget value sent to the AI is prefixed with the currency code so the strategy brief gets the right context. - Budget-related optimizer tools (budget, budget-planner, bid-strategy) have the "$" stripped from labels + placeholders and replaced with a "Your selected currency (see Settings)" hint. 3. Pre-fill + auto-save audit - All 29 optimizer/generator tools go through GeneratorShell, which calls applySmartFill on field-name heuristics covering 40+ aliases (audience, audience_who, target_audience, product, our_product, brand, brand_name, vertical, niche, voice, mood, etc.). Pre-fill confirmed working end-to-end. - getAutoSave() default true → every successful generation saves a GeneratedAd record linked to the active brand. Verified saveAd is called in the GeneratorShell happy path and now uses finalText so empty-text ads can never be saved.
1 parent 0d74a64 commit 75484f8

10 files changed

Lines changed: 160 additions & 20 deletions

File tree

app/brand/new/page.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { deterministicFillFromMetadata } from "@/lib/deterministic-brand-fill";
2020
import { applyIndustryFallback } from "@/lib/industry-fallback";
2121
import { saveDraft, loadDraft, clearDraft } from "@/lib/brand-draft";
2222
import { getProvider } from "@/lib/providers";
23+
import { formatMoney } from "@/lib/currency";
2324
import { providerSupportsVision, fileToImagePart } from "@/lib/providers/vision";
2425
import type { ContentPart, ImagePart } from "@/lib/providers/types";
2526

@@ -740,7 +741,7 @@ function Inner() {
740741
</label>
741742
{costPreview > 0 ? (
742743
<span className="text-[11px] font-mono uppercase tracking-ui-wide text-ink-faint tabular">
743-
${costPreview.toFixed(4)} {lightMode ? "(light)" : "(full)"}
744+
{formatMoney(costPreview, { fromUsd: true, decimals: 4 })} {lightMode ? "(light)" : "(full)"}
744745
</span>
745746
) : null}
746747
</div>

app/launch/wizard/page.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
type LaunchWizardCommon,
2222
} from "@/lib/prompts/launch-wizard";
2323
import { rememberLastGenerated } from "@/lib/next-steps";
24+
import { getCurrency } from "@/lib/currency";
2425

2526
type PhaseStatus = "pending" | "running" | "done" | "error";
2627
interface Phase {
@@ -148,9 +149,13 @@ function Inner() {
148149
);
149150
const cost = estimateCostUsd(res.providerId, res.modelId, res.usage);
150151
addUsage(cost, res.usage?.input_tokens ?? 0, res.usage?.output_tokens ?? 0);
151-
const json = args.expectJson === false ? null : tryParseJson<any>(res.text);
152-
updatePhase(args.key, { status: "done", result: json, text: res.text });
153-
return { json, text: res.text, modelId: res.modelId, usage: res.usage, cost };
152+
// res.text might be empty if the provider returned content only via deltas
153+
// (some Gemini responses do this). Fall back to our accumulated buffer so
154+
// the UI doesn't show "queued" on a successfully completed phase.
155+
const finalText = res.text || accumulated;
156+
const json = args.expectJson === false ? null : tryParseJson<any>(finalText);
157+
updatePhase(args.key, { status: "done", result: json, text: finalText });
158+
return { json, text: finalText, modelId: res.modelId, usage: res.usage, cost };
154159
} catch (e: any) {
155160
// The user pressed Stop — surface no failover, no error spam.
156161
if (args.signal?.aborted) {
@@ -199,9 +204,10 @@ function Inner() {
199204
);
200205
const cost = estimateCostUsd(res.providerId, res.modelId, res.usage);
201206
addUsage(cost, res.usage?.input_tokens ?? 0, res.usage?.output_tokens ?? 0);
202-
const json = args.expectJson === false ? null : tryParseJson<any>(res.text);
203-
updatePhase(args.key, { status: "done", result: json, text: res.text });
204-
return { json, text: res.text, modelId: res.modelId, usage: res.usage, cost };
207+
const finalText = res.text || accumulated;
208+
const json = args.expectJson === false ? null : tryParseJson<any>(finalText);
209+
updatePhase(args.key, { status: "done", result: json, text: finalText });
210+
return { json, text: finalText, modelId: res.modelId, usage: res.usage, cost };
205211
} catch {
206212
// fall through to error state below
207213
} finally {
@@ -241,7 +247,7 @@ function Inner() {
241247
campaign_name: campaignName.trim(),
242248
goal,
243249
platforms,
244-
budget_total: budget.trim(),
250+
budget_total: budget.trim() ? `${getCurrency().code} ${budget.trim()}` : "",
245251
launch_date: launchDate,
246252
duration,
247253
notes: notes.trim() || undefined,
@@ -412,7 +418,7 @@ function Inner() {
412418
goal,
413419
status: "planning",
414420
created_at: Date.now(),
415-
notes: `Launch date ${launchDate} · duration ${duration} · budget ${budget} · platforms ${platforms.join(", ")}`,
421+
notes: `Launch date ${launchDate} · duration ${duration} · budget ${getCurrency().symbol}${budget} · platforms ${platforms.join(", ")}`,
416422
};
417423
await saveCampaign(camp);
418424
setCampaignId(camp.id);
@@ -421,7 +427,7 @@ function Inner() {
421427
campaign_name: campaignName.trim(),
422428
goal,
423429
platforms,
424-
budget_total: budget.trim(),
430+
budget_total: budget.trim() ? `${getCurrency().code} ${budget.trim()}` : "",
425431
launch_date: launchDate,
426432
duration,
427433
notes: notes.trim() || undefined,
@@ -615,8 +621,8 @@ function Inner() {
615621

616622
<div className="grid grid-cols-2 gap-2">
617623
<div>
618-
<label className="label">Total budget</label>
619-
<input className="input-base" value={budget} onChange={(e) => setBudget(e.target.value)} placeholder="$5,000" />
624+
<label className="label">Total budget ({getCurrency().code})</label>
625+
<input className="input-base" value={budget} onChange={(e) => setBudget(e.target.value)} placeholder={`${getCurrency().symbol}5,000`} />
620626
</div>
621627
<div>
622628
<label className="label">Launch date</label>

app/optimize/bid-strategy/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ const config: GeneratorConfig<BidStrategyInput & Record<string, unknown>> = {
4242
{ name: "current_strategy_days_active", label: "Days since this strategy was last changed", kind: "number", placeholder: "e.g. 21", hint: "Smart Bidding needs 7+ uninterrupted days to learn. Below that, the AI flags it." },
4343

4444
// ----- Performance data -----
45-
{ name: "monthly_budget", label: "Budget / month", kind: "text", required: true, section: "Performance data", placeholder: "$5000" },
45+
{ name: "monthly_budget", label: "Budget / month", kind: "text", required: true, section: "Performance data", placeholder: "5000", hint: "Your selected currency (see Settings)." },
4646
{ name: "conversions_per_month", label: "Conversions / month (campaign)", kind: "number", required: true, placeholder: "35" },
4747
{ name: "conversions_last_7d", label: "Conversions in last 7 days (campaign)", kind: "number", placeholder: "e.g. 9", hint: "Smart Bidding tiers: ≥ 15/week for Target CPA, ≥ 30/week for Target ROAS." },
4848
{ name: "account_monthly_conversions", label: "Account-wide conversions / month", kind: "number", placeholder: "e.g. 120", hint: "Some platforms (Google) borrow signal across campaigns. Higher = faster learning." },

app/optimize/budget-planner/page.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ const config: GeneratorConfig<BudgetPlannerInput & Record<string, unknown>> = {
1212
campaign_type: "Budget Plan",
1313
maxTokens: 3500,
1414
fields: [
15-
{ name: "total_monthly", label: "Total monthly budget ($)", kind: "text", required: true, placeholder: "5000" },
15+
{ name: "total_monthly", label: "Total monthly budget", kind: "text", required: true, placeholder: "5000", hint: "Your selected currency (see Settings)." },
1616
{ name: "goal", label: "Goal", kind: "text", required: true, placeholder: "trial signups / sales / leads" },
1717
{ name: "business_type", label: "Business type", kind: "text", required: true, placeholder: "B2B SaaS / ecommerce / local service" },
18-
{ name: "current_aov_or_ltv", label: "AOV / LTV ($)", kind: "text", placeholder: "120 LTV" },
18+
{ name: "current_aov_or_ltv", label: "AOV / LTV", kind: "text", placeholder: "120 LTV", hint: "Your selected currency." },
1919
{ name: "current_cvr", label: "Site CVR (%)", kind: "text", placeholder: "2.4" },
2020
{ name: "has_organic", label: "Organic traffic?", kind: "text", placeholder: "10k/mo SEO" },
2121
{ name: "geo", label: "Geo", kind: "text", placeholder: "US + Canada", span: 2 },

app/optimize/budget/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const config: GeneratorConfig<BudgetWasteInput & Record<string, unknown>> = {
2323
{ value: "LinkedIn Ads", label: "LinkedIn Ads" },
2424
],
2525
},
26-
{ name: "monthly_spend", label: "Monthly spend ($)", kind: "text", required: true, placeholder: "5000" },
26+
{ name: "monthly_spend", label: "Monthly spend", kind: "text", required: true, placeholder: "5000", hint: "Your selected currency (see Settings)." },
2727
{ name: "campaign_summary", label: "Campaigns & setup", kind: "textarea", required: true, rows: 5, placeholder: "Describe your campaigns: structure, audiences, match types, recent CPA, conversion volume.", span: 2 },
2828
{ name: "match_types", label: "Match types used", kind: "text", placeholder: "broad / phrase / exact" },
2929
{ name: "has_negatives", label: "Negative list?", kind: "text", placeholder: "yes / no / partial" },

app/settings/page.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { PROVIDERS, type Provider } from "@/lib/providers";
1717
import { testApiKey } from "@/lib/llm";
1818
import { exportAll, importAll, wipeAll } from "@/lib/storage";
1919
import { formatCost, formatTokens } from "@/lib/utils";
20+
import { CURRENCIES, getCurrencyCode, setCurrencyCode } from "@/lib/currency";
2021

2122
export default function SettingsPage() {
2223
return (
@@ -41,6 +42,7 @@ function SettingsInner() {
4142
const [autoSave, setAutoSaveState] = useState(true);
4243
const [jina, setJinaState] = useState("");
4344
const [syncKeys, setSyncKeysState] = useState(false);
45+
const [currency, setCurrencyState] = useState("USD");
4446
const importRef = useRef<HTMLInputElement>(null);
4547

4648
useEffect(() => {
@@ -59,6 +61,7 @@ function SettingsInner() {
5961
setCharWarnState(getCharWarn());
6062
setAutoSaveState(getAutoSave());
6163
setJinaState(getJinaKey());
64+
setCurrencyState(getCurrencyCode());
6265
if (typeof window !== "undefined") {
6366
setSyncKeysState(window.localStorage.getItem("ados.sync_include_keys") === "1");
6467
}
@@ -284,6 +287,21 @@ function SettingsInner() {
284287
<label className="label">default language for generated copy</label>
285288
<input className="input-base" value={lang} onChange={(e) => persistLang(e.target.value)} placeholder="English / Spanish / Hindi / Arabic …" />
286289
</div>
290+
<div>
291+
<label className="label">currency (budgets + cost displays)</label>
292+
<select
293+
className="input-base"
294+
value={currency}
295+
onChange={(e) => { setCurrencyState(e.target.value); setCurrencyCode(e.target.value); }}
296+
>
297+
{CURRENCIES.map((c) => (
298+
<option key={c.code} value={c.code}>{c.symbol} {c.code}{c.label}</option>
299+
))}
300+
</select>
301+
<p className="text-[11px] text-ink-muted mt-1.5">
302+
Applied to budget inputs in optimizers + launch wizard, and to AI cost previews. Exchange rates are coarse approximations — accurate enough for previews, not accounting.
303+
</p>
304+
</div>
287305
<div>
288306
<label className="label">tone override (optional)</label>
289307
<input className="input-base" value={tone} onChange={(e) => persistTone(e.target.value)} placeholder="punchy, irreverent — overrides brand brain tone" />

components/GeneratorShell.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,9 +224,14 @@ function Inner<I extends Record<string, unknown>>({ config, scope }: Props<I>) {
224224
addUsage(cost, res.usage?.input_tokens ?? 0, res.usage?.output_tokens ?? 0);
225225
window.dispatchEvent(new Event("ados:usage"));
226226

227+
// Some providers (Gemini in particular) occasionally return res.text empty
228+
// even when streaming deltas worked correctly. Fall back to the accumulated
229+
// streamed buffer so the user's output (and any auto-saved ad) is never blank.
230+
const finalText = res.text || stream.text;
231+
227232
let json: any = null;
228233
if (config.expectJson !== false) {
229-
json = tryParseJson(res.text);
234+
json = tryParseJson(finalText);
230235
setParsed(json);
231236
}
232237

@@ -239,7 +244,7 @@ function Inner<I extends Record<string, unknown>>({ config, scope }: Props<I>) {
239244
title: config.buildTitle(input),
240245
input: input as unknown as Record<string, unknown>,
241246
output_json: json,
242-
output_text: res.text,
247+
output_text: finalText,
243248
model_id: res.modelId,
244249
usage_input_tokens: res.usage?.input_tokens ?? 0,
245250
usage_output_tokens: res.usage?.output_tokens ?? 0,

lib/currency.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* Currency support for budget inputs + cost displays. Stored in localStorage
3+
* as a single setting that applies app-wide. Defaults to USD.
4+
*
5+
* For AI cost previews ($ pricing from providers is always USD), we convert
6+
* to the user's chosen currency for display only. Exchange rates are coarse
7+
* hardcoded values — accurate enough for "≈ ₹0.20 per extraction" preview
8+
* but explicitly not for accounting.
9+
*/
10+
11+
export interface Currency {
12+
code: string;
13+
symbol: string;
14+
label: string;
15+
/** Approximate units per 1 USD. Used only for cost-preview display. */
16+
per_usd: number;
17+
}
18+
19+
export const CURRENCIES: Currency[] = [
20+
{ code: "USD", symbol: "$", label: "US Dollar", per_usd: 1 },
21+
{ code: "INR", symbol: "₹", label: "Indian Rupee", per_usd: 84 },
22+
{ code: "EUR", symbol: "€", label: "Euro", per_usd: 0.92 },
23+
{ code: "GBP", symbol: "£", label: "British Pound", per_usd: 0.79 },
24+
{ code: "CAD", symbol: "C$", label: "Canadian Dollar", per_usd: 1.36 },
25+
{ code: "AUD", symbol: "A$", label: "Australian Dollar", per_usd: 1.50 },
26+
{ code: "AED", symbol: "AED ", label: "UAE Dirham", per_usd: 3.67 },
27+
{ code: "SGD", symbol: "S$", label: "Singapore Dollar", per_usd: 1.34 },
28+
{ code: "JPY", symbol: "¥", label: "Japanese Yen", per_usd: 155 },
29+
{ code: "MXN", symbol: "MX$", label: "Mexican Peso", per_usd: 17.3 },
30+
{ code: "BRL", symbol: "R$", label: "Brazilian Real", per_usd: 5.7 },
31+
{ code: "ZAR", symbol: "R", label: "South African Rand", per_usd: 18.2 },
32+
];
33+
34+
const KEY = "ados.currency";
35+
36+
function safeLocal(): Storage | null {
37+
if (typeof window === "undefined") return null;
38+
try { return window.localStorage; } catch { return null; }
39+
}
40+
41+
export function getCurrencyCode(): string {
42+
return safeLocal()?.getItem(KEY) || "USD";
43+
}
44+
45+
export function setCurrencyCode(code: string): void {
46+
const s = safeLocal();
47+
if (!s) return;
48+
if (CURRENCIES.find((c) => c.code === code)) {
49+
try {
50+
s.setItem(KEY, code);
51+
window.dispatchEvent(new Event("ados:currency-changed"));
52+
} catch {}
53+
}
54+
}
55+
56+
export function getCurrency(): Currency {
57+
const code = getCurrencyCode();
58+
return CURRENCIES.find((c) => c.code === code) ?? CURRENCIES[0];
59+
}
60+
61+
/** Format an amount in the user's currency. For amounts originally in USD
62+
* (e.g. AI cost estimates), set `fromUsd` true to convert via rate. For
63+
* user-entered budgets already in the local currency, leave `fromUsd` false. */
64+
export function formatMoney(amount: number, opts: { fromUsd?: boolean; decimals?: number } = {}): string {
65+
const cur = getCurrency();
66+
const converted = opts.fromUsd ? amount * cur.per_usd : amount;
67+
const decimals = opts.decimals ?? (converted < 1 ? 4 : converted < 100 ? 2 : 0);
68+
// Use Intl.NumberFormat where available for proper locale grouping.
69+
let body: string;
70+
try {
71+
body = new Intl.NumberFormat(undefined, {
72+
minimumFractionDigits: decimals,
73+
maximumFractionDigits: decimals,
74+
}).format(converted);
75+
} catch {
76+
body = converted.toFixed(decimals);
77+
}
78+
return `${cur.symbol}${body}`;
79+
}
80+
81+
/** Strip currency symbols/groupings from a user-typed string so we can parse a
82+
* numeric value for storage. Handles "₹5,00,000", "$1,000", "1 lakh" loosely. */
83+
export function parseMoneyInput(raw: string): number {
84+
if (!raw) return 0;
85+
// Replace common Indian shorthand
86+
let v = raw.toLowerCase().trim();
87+
const lakh = /([\d.]+)\s*lakh/;
88+
const crore = /([\d.]+)\s*crore/;
89+
if (crore.test(v)) {
90+
const m = v.match(crore);
91+
return m ? Number(m[1]) * 10_000_000 : 0;
92+
}
93+
if (lakh.test(v)) {
94+
const m = v.match(lakh);
95+
return m ? Number(m[1]) * 100_000 : 0;
96+
}
97+
// Strip non-numeric chars except dot
98+
const cleaned = v.replace(/[^\d.]/g, "");
99+
return Number(cleaned) || 0;
100+
}

lib/settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const KEYS = {
1212
toneOverride: "ados.tone_override",
1313
charWarn: "ados.char_warn",
1414
autoSave: "ados.autosave",
15+
currency: "ados.currency",
1516
// Per-provider keys: `ados.provider.{id}.key`
1617
// Per-provider model: `ados.provider.{id}.model`
1718
// Legacy (migrated): "ados.api_key", "ados.model"

lib/utils.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,17 @@ export function cn(...inputs: ClassValue[]): string {
66
}
77

88
export function formatCost(usd: number): string {
9-
if (usd < 0.01) return `$${usd.toFixed(4)}`;
10-
return `$${usd.toFixed(2)}`;
9+
// Routes through the currency setting so /history, StatusBar, and any other
10+
// cost-display surface honor the user's chosen currency.
11+
if (typeof window === "undefined") {
12+
// SSR fallback to USD.
13+
if (usd < 0.01) return `$${usd.toFixed(4)}`;
14+
return `$${usd.toFixed(2)}`;
15+
}
16+
// Lazy import so SSR doesn't choke on the browser-only localStorage path.
17+
// eslint-disable-next-line @typescript-eslint/no-var-requires
18+
const { formatMoney } = require("./currency");
19+
return formatMoney(usd, { fromUsd: true });
1120
}
1221

1322
export function formatTokens(n: number): string {

0 commit comments

Comments
 (0)