Skip to content

Commit 540d7e5

Browse files
committed
feat: add affiliate dashboard and monetization features
- Implemented affiliate dashboard with overview, referrals, payouts, and tiers pages. - Created monetization actions for admin to manage affiliate withdrawals and commissions. - Added credit usage page to track AI credits and transactions. - Developed AI field assist for generating content using AI. - Introduced new types and API functions for affiliate and credit management.
1 parent 30a39ee commit 540d7e5

74 files changed

Lines changed: 4159 additions & 315 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ yarn-error.log*
3232

3333
# env files (can opt-in for committing if needed)
3434
.env*
35+
.private/
36+
veriworkly_ai_credit_spec.md
3537

3638
# except example file
3739
!.env.example
@@ -64,4 +66,4 @@ portfolio_static_rendering.md
6466
portfolio_nextjs_architecture.md
6567
portfolio_nextjs_static_production.md
6668
portfolio_production_comparison.md
67-
portfolio_system_specification.md
69+
portfolio_system_specification.md

apps/portfolio/components/dashboard/editor/ContentCanvas.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Field } from "./Field";
55
import { AssetUpload } from "./AssetUpload";
66
import { SectionEditor } from "./SectionEditor";
77
import { inputClass as input } from "./constants";
8+
import { PortfolioAiAssist } from "./PortfolioAiAssist";
89

910
export interface ContentCanvasProps {
1011
selectedSectionId: string;
@@ -14,6 +15,7 @@ export interface ContentCanvasProps {
1415
export function ContentCanvas({ selectedSectionId, onClose }: ContentCanvasProps) {
1516
const content = usePortfolioStore((state) => state.content);
1617
const updateIdentity = usePortfolioStore((state) => state.updateIdentity);
18+
const documentId = usePortfolioStore((state) => state.draft?.id);
1719
const selectedSection = content.sections.find((section) => section.id === selectedSectionId);
1820

1921
return (
@@ -52,6 +54,12 @@ export function ContentCanvas({ selectedSectionId, onClose }: ContentCanvasProps
5254
onChange={(e) => updateIdentity({ headline: e.target.value })}
5355
/>
5456
</Field>
57+
<PortfolioAiAssist
58+
context={JSON.stringify({ name: content.identity.name, bio: content.identity.bio })}
59+
documentId={documentId}
60+
onApply={(headline) => updateIdentity({ headline })}
61+
text={content.identity.headline}
62+
/>
5563
<Field label="Short introduction">
5664
<textarea
5765
className={input}
@@ -60,6 +68,15 @@ export function ContentCanvas({ selectedSectionId, onClose }: ContentCanvasProps
6068
onChange={(e) => updateIdentity({ bio: e.target.value })}
6169
/>
6270
</Field>
71+
<PortfolioAiAssist
72+
context={JSON.stringify({
73+
identity: content.identity,
74+
sections: content.sections.map(({ type, title, items }) => ({ type, title, items })),
75+
})}
76+
documentId={documentId}
77+
onApply={(bio) => updateIdentity({ bio })}
78+
text={content.identity.bio}
79+
/>
6380
<div className="grid gap-3 sm:grid-cols-2">
6481
<Field label="Location">
6582
<input
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
5+
import { Sparkles, X } from "lucide-react";
6+
7+
import {
8+
generateAiContent,
9+
getAiActions,
10+
type AiActionPolicy,
11+
type AiMode,
12+
} from "@/lib/ai-client";
13+
14+
import { actionClass, inputClass } from "./constants";
15+
16+
export function PortfolioAiAssist({
17+
text,
18+
context,
19+
documentId,
20+
onApply,
21+
}: {
22+
text: string;
23+
context: string;
24+
documentId?: string;
25+
onApply: (content: string) => void;
26+
}) {
27+
const [open, setOpen] = useState(false);
28+
const [mode, setMode] = useState<AiMode>("standard");
29+
const [instructions, setInstructions] = useState("");
30+
const [result, setResult] = useState("");
31+
const [message, setMessage] = useState("");
32+
const [loading, setLoading] = useState(false);
33+
const [actions, setActions] = useState<AiActionPolicy | null>(null);
34+
35+
useEffect(() => {
36+
if (!open || actions) return;
37+
void getAiActions().then(setActions).catch((error) => setMessage(error.message));
38+
}, [actions, open]);
39+
40+
if (!open) {
41+
return (
42+
<button
43+
className={`${actionClass} text-accent mt-1 px-0`}
44+
onClick={() => setOpen(true)}
45+
type="button"
46+
>
47+
<Sparkles size={13} /> Improve with AI
48+
</button>
49+
);
50+
}
51+
52+
const cost = actions?.generate_portfolio_copy.costs[mode];
53+
54+
return (
55+
<div className="border-line bg-paper mt-2 space-y-2.5 rounded-xl border p-3">
56+
<div className="flex items-center justify-between">
57+
<p className="text-xs font-extrabold">AI writing assistant</p>
58+
<button aria-label="Close AI assistant" onClick={() => setOpen(false)} type="button">
59+
<X className="text-muted" size={14} />
60+
</button>
61+
</div>
62+
<div className="flex gap-2">
63+
{(["standard", "expert"] as const).map((option) => (
64+
<button
65+
className={`${actionClass} ${mode === option ? "bg-accent text-white" : "border-line bg-panel border"}`}
66+
key={option}
67+
onClick={() => setMode(option)}
68+
type="button"
69+
>
70+
{option === "standard" ? "Standard" : "Expert"}
71+
{actions ? ` · ${actions.generate_portfolio_copy.costs[option]} cr` : ""}
72+
</button>
73+
))}
74+
</div>
75+
<input
76+
className={inputClass}
77+
maxLength={500}
78+
onChange={(event) => setInstructions(event.target.value)}
79+
placeholder="Optional direction or tone"
80+
value={instructions}
81+
/>
82+
{result ? (
83+
<>
84+
<textarea
85+
aria-label="Generated portfolio draft"
86+
className={inputClass}
87+
onChange={(event) => setResult(event.target.value)}
88+
rows={5}
89+
value={result}
90+
/>
91+
<div className="flex gap-2">
92+
<button
93+
className={`${actionClass} bg-accent text-white`}
94+
onClick={() => {
95+
onApply(result);
96+
setOpen(false);
97+
setResult("");
98+
}}
99+
type="button"
100+
>
101+
Replace field
102+
</button>
103+
<button
104+
className={`${actionClass} border-line bg-panel border`}
105+
onClick={() => setResult("")}
106+
type="button"
107+
>
108+
Discard draft
109+
</button>
110+
</div>
111+
</>
112+
) : (
113+
<button
114+
className={`${actionClass} bg-accent text-white`}
115+
disabled={loading || cost == null}
116+
onClick={async () => {
117+
setLoading(true);
118+
setMessage("");
119+
try {
120+
const response = await generateAiContent({
121+
action: "generate_portfolio_copy",
122+
mode,
123+
input: { text, context, instructions },
124+
requestId: crypto.randomUUID(),
125+
documentId,
126+
});
127+
setResult(response.content);
128+
setMessage(`${response.credits.spent} credits used. Balance ${response.credits.balance}.`);
129+
} catch (error) {
130+
setMessage(error instanceof Error ? error.message : "AI generation failed.");
131+
} finally {
132+
setLoading(false);
133+
}
134+
}}
135+
type="button"
136+
>
137+
{loading ? "Generating..." : `Generate draft${cost == null ? "" : ` · ${cost} credits`}`}
138+
</button>
139+
)}
140+
<p className="text-muted text-[11px] leading-4">
141+
{message ||
142+
"Generation sends this field and relevant portfolio context to the configured AI provider. Your current text stays unchanged until you replace the field."}
143+
</p>
144+
</div>
145+
);
146+
}

apps/portfolio/components/dashboard/editor/SectionEditor.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import { Field } from "./Field";
66
import { ItemAction } from "./ItemAction";
77
import { AssetUpload } from "./AssetUpload";
88
import { actionClass as action, inputClass as input, sectionInfo } from "./constants";
9+
import { PortfolioAiAssist } from "./PortfolioAiAssist";
910

1011
export interface SectionEditorProps {
1112
section: PortfolioSection;
1213
}
1314

1415
export function SectionEditor({ section }: SectionEditorProps) {
1516
const updateSection = usePortfolioStore((state) => state.updateSection);
17+
const documentId = usePortfolioStore((state) => state.draft?.id);
1618
const replaceItems = (items: Array<Record<string, unknown>>) =>
1719
updateSection(section.id, { items });
1820
const updateItem = (index: number, patch: Record<string, unknown>) =>
@@ -102,6 +104,17 @@ export function SectionEditor({ section }: SectionEditorProps) {
102104
onChange={(e) => updateItem(index, { summary: e.target.value })}
103105
/>
104106
</Field>
107+
<PortfolioAiAssist
108+
context={JSON.stringify({
109+
sectionType: section.type,
110+
sectionTitle: section.title,
111+
itemTitle: item.title,
112+
year: item.year,
113+
})}
114+
documentId={documentId}
115+
onApply={(summary) => updateItem(index, { summary })}
116+
text={String(item.summary ?? "")}
117+
/>
105118
{section.type === "projects" ? (
106119
<AssetUpload
107120
kind="PROJECT_COVER"

apps/portfolio/lib/ai-client.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"use client";
2+
3+
import { authenticatedFetch } from "@/lib/authenticated-fetch";
4+
5+
export type AiAction =
6+
| "rewrite_short_text"
7+
| "rewrite_section"
8+
| "generate_section"
9+
| "generate_portfolio_copy"
10+
| "generate_cover_letter"
11+
| "tailor_resume_to_job"
12+
| "generate_document";
13+
export type AiMode = "standard" | "expert";
14+
export type AiActionPolicy = Record<
15+
AiAction,
16+
{
17+
costs: Record<AiMode, number>;
18+
}
19+
>;
20+
export type AiGenerateResult = {
21+
content: string;
22+
usage: { promptTokens: number; completionTokens: number; totalTokens: number } | null;
23+
credits: { spent: number; balance: number };
24+
};
25+
26+
type ApiResponse<T> = { data?: T; message?: string };
27+
let actionsPromise: Promise<AiActionPolicy> | null = null;
28+
29+
async function readResponse<T>(response: Response) {
30+
const payload = (await response.json().catch(() => ({}))) as ApiResponse<T>;
31+
if (!response.ok || !payload.data) throw new Error(payload.message || "AI request failed.");
32+
return payload.data;
33+
}
34+
35+
export function getAiActions() {
36+
actionsPromise ??= authenticatedFetch("/ai/actions")
37+
.then((response) => readResponse<AiActionPolicy>(response))
38+
.catch((error) => {
39+
actionsPromise = null;
40+
throw error;
41+
});
42+
return actionsPromise;
43+
}
44+
45+
export async function generateAiContent(input: {
46+
action: AiAction;
47+
mode: AiMode;
48+
input: { text?: string; context?: string; instructions?: string };
49+
requestId: string;
50+
documentId?: string;
51+
}) {
52+
return readResponse<AiGenerateResult>(
53+
await authenticatedFetch("/ai/generate", {
54+
method: "POST",
55+
headers: { "Content-Type": "application/json" },
56+
body: JSON.stringify(input),
57+
}),
58+
);
59+
}

apps/server/.env.example

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,26 @@ RATE_LIMIT_MAX_REQUESTS=100
5858

5959
LOG_LEVEL=info
6060

61+
# =========================================================
62+
# AI Provider
63+
# =========================================================
64+
65+
# Development uses NVIDIA NIM. Production uses OpenRouter.
66+
NVIDIA_API_KEY=
67+
NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
68+
OPENROUTER_API_KEY=
69+
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
70+
AI_STANDARD_MODEL=
71+
AI_EXPERT_MODEL=
72+
# Point to an ignored/mounted policy file, or inject the same JSON through AI_PRIVATE_CONFIG_JSON.
73+
# The policy contains model routing, prompts, token limits, and standard/expert credit costs.
74+
AI_PRIVATE_CONFIG_PATH=
75+
AI_PRIVATE_CONFIG_JSON=
76+
AI_TIMEOUT_MS=120000
77+
AI_RATE_LIMIT_WINDOW_MS=60000
78+
AI_RATE_LIMIT_MAX_REQUESTS=20
79+
SITE_URL=https://veriworkly.com
80+
6181

6282
# =========================================================
6383
# 🔑 Authentication
@@ -158,6 +178,14 @@ DODO_PAYMENTS_ENVIRONMENT=test_mode
158178
DODO_PAYMENTS_SEVEN_DAY_PRODUCT_ID=
159179
DODO_PAYMENTS_MONTHLY_PRODUCT_ID=
160180
DODO_PAYMENTS_ANNUAL_PRODUCT_ID=
181+
# The legacy monthly/annual ids above remain Portfolio Pro fallbacks.
182+
DODO_PAYMENTS_PORTFOLIO_PRO_MONTHLY_PRODUCT_ID=
183+
DODO_PAYMENTS_PORTFOLIO_PRO_ANNUAL_PRODUCT_ID=
184+
DODO_PAYMENTS_AI_CREDITS_MONTHLY_PRODUCT_ID=
185+
DODO_PAYMENTS_AI_CREDITS_ANNUAL_PRODUCT_ID=
186+
DODO_PAYMENTS_BUNDLE_MONTHLY_PRODUCT_ID=
187+
DODO_PAYMENTS_BUNDLE_ANNUAL_PRODUCT_ID=
188+
DODO_PAYMENTS_CREDIT_PACK_100_PRODUCT_ID=
161189
DODO_PAYMENTS_CHECKOUT_RETURN_URL=http://localhost:3001/billing?checkout=complete
162190
DODO_PAYMENTS_CHECKOUT_CANCEL_URL=http://localhost:3001/billing?checkout=cancelled
163191
DODO_PAYMENTS_PORTAL_RETURN_URL=http://localhost:3001/billing

apps/server/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"helmet": "^7.2.0",
3939
"node-cron": "^4.2.1",
4040
"nodemailer": "^8.0.10",
41+
"openai": "^6.42.0",
4142
"pg": "^8.21.0",
4243
"redis": "^5.12.1",
4344
"uuid": "^14.0.0",

0 commit comments

Comments
 (0)