Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ STRIPE_WEBHOOK_SECRET=
VERCEL_ORG_ID=
VERCEL_PROJECT_ID=
VERCEL_TOKEN=
YAHOO_FINANCE_API_URL=
14 changes: 14 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,16 @@ jobs:
vercel env add APPWRITE_COLLECTION_ID_AI_KEYS $ENV_TYPE "" --value "${{ secrets.APPWRITE_COLLECTION_ID_AI_KEYS }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }}
vercel env add APPWRITE_COLLECTION_ID_TRIAL_SESSIONS $ENV_TYPE "" --value "${{ secrets.APPWRITE_COLLECTION_ID_TRIAL_SESSIONS }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }}
vercel env add APPWRITE_COLLECTION_ID_SUBSCRIPTIONS $ENV_TYPE "" --value "${{ secrets.APPWRITE_COLLECTION_ID_SUBSCRIPTIONS }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }}
if [ -n "${{ vars.FINNHUB_BASE_URL }}" ]; then
vercel env add FINNHUB_BASE_URL $ENV_TYPE "" --value "${{ vars.FINNHUB_BASE_URL }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }}
fi
vercel env add FINNHUB_API_KEY $ENV_TYPE "" --value "${{ secrets.FINNHUB_API_KEY }}" --yes --force --sensitive --token=${{ secrets.VERCEL_TOKEN }}
if [ -n "${{ vars.OLLAMA_MODEL }}" ]; then
vercel env add OLLAMA_MODEL $ENV_TYPE "" --value "${{ vars.OLLAMA_MODEL }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }}
fi
if [ -n "${{ vars.YAHOO_FINANCE_API_URL }}" ]; then
vercel env add YAHOO_FINANCE_API_URL $ENV_TYPE "" --value "${{ vars.YAHOO_FINANCE_API_URL }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }}
fi
if [ -n "${{ vars.STRIPE_PRICE_ADS_FREE }}" ]; then
vercel env add STRIPE_PRICE_ADS_FREE $ENV_TYPE "" --value "${{ vars.STRIPE_PRICE_ADS_FREE }}" --yes --force --token=${{ secrets.VERCEL_TOKEN }}
fi
Expand Down Expand Up @@ -216,6 +226,10 @@ jobs:
APPWRITE_COLLECTION_ID_AI_KEYS: ${{ secrets.APPWRITE_COLLECTION_ID_AI_KEYS }}
APPWRITE_COLLECTION_ID_TRIAL_SESSIONS: ${{ secrets.APPWRITE_COLLECTION_ID_TRIAL_SESSIONS }}
APPWRITE_COLLECTION_ID_SUBSCRIPTIONS: ${{ secrets.APPWRITE_COLLECTION_ID_SUBSCRIPTIONS }}
FINNHUB_BASE_URL: ${{ vars.FINNHUB_BASE_URL }}
FINNHUB_API_KEY: ${{ secrets.FINNHUB_API_KEY }}
OLLAMA_MODEL: ${{ vars.OLLAMA_MODEL }}
YAHOO_FINANCE_API_URL: ${{ vars.YAHOO_FINANCE_API_URL }}
STRIPE_PRICE_ADS_FREE: ${{ vars.STRIPE_PRICE_ADS_FREE }}
STRIPE_PRICE_LOCAL: ${{ vars.STRIPE_PRICE_LOCAL }}
STRIPE_PRICE_BYOK: ${{ vars.STRIPE_PRICE_BYOK }}
Expand Down
7 changes: 6 additions & 1 deletion app/(main)/home-page-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,10 @@ export function HomePageClient() {
`/api/market/symbol/${selectedSymbol}`
);
if (!symbolResponse.ok) {
throw new Error("Failed to fetch symbol data");
const body = (await symbolResponse.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(body.error ?? "Failed to fetch symbol data");
}
const symbolResult = await symbolResponse.json();
setSymbolData(symbolResult.data);
Expand Down Expand Up @@ -531,6 +534,7 @@ export function HomePageClient() {
loading={aiPredictionLoading}
locked={!hasAIAccess}
error={aiPredictionError}
pricingTier={effectiveTier}
/>
</>
)}
Expand Down Expand Up @@ -569,6 +573,7 @@ export function HomePageClient() {
loading={stockOfTheDayLoading}
locked={!hasAIAccess}
error={stockOfTheDayError}
pricingTier={effectiveTier}
/>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions app/(main)/stock-of-the-day/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export default function StockOfTheDayPage() {
loading={loading}
locked={!hasAIAccess}
error={loadError}
pricingTier={pricingTier}
/>
</div>
);
Expand Down
66 changes: 14 additions & 52 deletions app/api/market/ai-prediction/[symbol]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,7 @@ import { aiMarketInsightsService } from "@/services/ai-market-insights.service";
import { logger } from "@/lib/logger";
import { getAuthenticatedUser } from "@/lib/server-auth";
import { subscriptionService } from "@/services/subscription.service";
import { appwriteAIKeyStoreService } from "@/services/appwrite-ai-key-store.service";
import type { AIProvider } from "@/types";
import type { BYOKProvider } from "@/services/api-key-manager.service";

function isBYOKProvider(value: string): value is BYOKProvider {
return ["OPENAI", "GEMINI", "MISTRAL", "DEEPSEEK"].includes(value);
}
import { resolveMarketRouteLLMConfig } from "@/lib/resolve-market-ai-llm-config";

export async function GET(
request: NextRequest,
Expand Down Expand Up @@ -43,54 +37,22 @@ export async function GET(

const requestedProviderRaw =
request.headers.get("x-ai-provider")?.trim().toUpperCase() ?? "";
const model = process.env.AI_MODEL;

let llmConfig:
| {
provider: AIProvider;
apiKey?: string;
model?: string;
}
| undefined;

if (tier === "LOCAL") {
llmConfig = {
provider: "OLLAMA",
model: process.env.OLLAMA_MODEL ?? model,
};
} else if (tier === "BYOK") {
const requestedProvider = isBYOKProvider(requestedProviderRaw)
? requestedProviderRaw
: null;
const provider =
requestedProvider ??
(await appwriteAIKeyStoreService.getPreferredProvider(auth.id));
if (!provider) {
return NextResponse.json(
{
success: false,
error:
"No BYOK API key found. Save a provider key in profile first.",
},
{ status: 403 }
);
}
const apiKey = await appwriteAIKeyStoreService.getDecryptedKey(
auth.id,
provider
);
if (!apiKey) {
return NextResponse.json(
{
success: false,
error: `No API key stored for provider ${provider}. Change provider in profile or save a key for ${provider}.`,
},
{ status: 403 }
);
}
llmConfig = { provider, apiKey, model };
const resolved = await resolveMarketRouteLLMConfig({
tier,
userId: auth.id,
requestedProviderRaw,
});
if (!resolved.ok) {
logger.warn("AI prediction: no LLM credentials; using heuristic only", {
userId: auth.id,
tier,
symbol,
detail: resolved.error,
});
}

const llmConfig = resolved.ok ? resolved.llmConfig : undefined;
const data = await aiMarketInsightsService.generatePrediction(
symbol,
llmConfig
Expand Down
64 changes: 14 additions & 50 deletions app/api/market/stock-of-the-day/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,7 @@ import { aiMarketInsightsService } from "@/services/ai-market-insights.service";
import { logger } from "@/lib/logger";
import { getAuthenticatedUser } from "@/lib/server-auth";
import { subscriptionService } from "@/services/subscription.service";
import { appwriteAIKeyStoreService } from "@/services/appwrite-ai-key-store.service";
import type { AIProvider } from "@/types";
import type { BYOKProvider } from "@/services/api-key-manager.service";

function isBYOKProvider(value: string): value is BYOKProvider {
return ["OPENAI", "GEMINI", "MISTRAL", "DEEPSEEK"].includes(value);
}
import { resolveMarketRouteLLMConfig } from "@/lib/resolve-market-ai-llm-config";

export async function GET(request: NextRequest) {
try {
Expand All @@ -31,54 +25,24 @@ export async function GET(request: NextRequest) {

const requestedProviderRaw =
request.headers.get("x-ai-provider")?.trim().toUpperCase() ?? "";
const model = process.env.AI_MODEL;

let llmConfig:
| {
provider: AIProvider;
apiKey?: string;
model?: string;
const resolved = await resolveMarketRouteLLMConfig({
tier,
userId: auth.id,
requestedProviderRaw,
});
if (!resolved.ok) {
logger.warn(
"Stock of the day: no LLM credentials; using heuristic only",
{
userId: auth.id,
tier,
detail: resolved.error,
}
| undefined;

if (tier === "LOCAL") {
llmConfig = {
provider: "OLLAMA",
model: process.env.OLLAMA_MODEL ?? model,
};
} else if (tier === "BYOK") {
const requestedProvider = isBYOKProvider(requestedProviderRaw)
? requestedProviderRaw
: null;
const provider =
requestedProvider ??
(await appwriteAIKeyStoreService.getPreferredProvider(auth.id));
if (!provider) {
return NextResponse.json(
{
success: false,
error:
"No BYOK API key found. Save a provider key in profile first.",
},
{ status: 403 }
);
}
const apiKey = await appwriteAIKeyStoreService.getDecryptedKey(
auth.id,
provider
);
if (!apiKey) {
return NextResponse.json(
{
success: false,
error: `No API key stored for provider ${provider}. Change provider in profile or save a key for ${provider}.`,
},
{ status: 403 }
);
}
llmConfig = { provider, apiKey, model };
}

const llmConfig = resolved.ok ? resolved.llmConfig : undefined;
const data = await aiMarketInsightsService.getStockOfTheDay(llmConfig);

return NextResponse.json({
Expand Down
12 changes: 7 additions & 5 deletions app/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import { Ibarra_Real_Nova, Merriweather } from "next/font/google";
import Script from "next/script";
import "./globals.css";
import { Providers } from "./providers";

Expand Down Expand Up @@ -37,13 +36,16 @@ export default function RootLayout({
<html lang="en">
<head>
{gtmId ? (
<Script id="gtm-script" strategy="afterInteractive">
{`(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
<script
id="gtm-script"
dangerouslySetInnerHTML={{
__html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer','${gtmId}');`}
</Script>
})(window,document,'script','dataLayer','${gtmId}');`,
}}
/>
) : null}
<meta
name="viewport"
Expand Down
7 changes: 5 additions & 2 deletions components/AIPredictionPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
"use client";

import Link from "next/link";
import type { AIPredictionReport } from "@/types";
import type { AIPredictionReport, PricingTier } from "@/types";
import { getAiSubscriptionGateMessage } from "@/lib/ai-subscription-ux";
import { isMissingByokApiKeyMessage } from "@/lib/missing-byok-api-key";

interface AIPredictionPanelProps {
prediction: AIPredictionReport | null;
loading: boolean;
locked: boolean;
error?: string | null;
pricingTier?: PricingTier | null;
}

function RecommendationBadge({
Expand Down Expand Up @@ -38,6 +40,7 @@ export function AIPredictionPanel({
loading,
locked,
error,
pricingTier,
}: AIPredictionPanelProps) {
const politicalFactors = prediction?.politicalFactors ?? [];
const financialTrendFactors = prediction?.financialTrendFactors ?? [];
Expand Down Expand Up @@ -155,7 +158,7 @@ export function AIPredictionPanel({
{locked && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-white/70 dark:bg-gray-900/70 px-6 text-center">
<p className="text-sm sm:text-base font-medium text-gray-900 dark:text-gray-100">
AI prediction is available only for AI subscriptions.
{getAiSubscriptionGateMessage(pricingTier ?? undefined)}
</p>
<Link
href="/pricing"
Expand Down
9 changes: 6 additions & 3 deletions components/StockOfTheDayPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
"use client";

import Link from "next/link";
import type { StockOfTheDay } from "@/types";
import type { PricingTier, StockOfTheDay } from "@/types";
import { getAiSubscriptionGateMessage } from "@/lib/ai-subscription-ux";
import { isMissingByokApiKeyMessage } from "@/lib/missing-byok-api-key";

interface StockOfTheDayPanelProps {
item: StockOfTheDay | null;
loading: boolean;
locked: boolean;
error?: string | null;
/** When locked, used to explain which upgrade path applies. */
pricingTier?: PricingTier | null;
}

export function StockOfTheDayPanel({
item,
loading,
locked,
error,
pricingTier,
}: StockOfTheDayPanelProps) {
return (
<section className="mt-6 sm:mt-8 lg:mt-10">
Expand Down Expand Up @@ -102,8 +106,7 @@ export function StockOfTheDayPanel({
{locked && (
<div className="absolute inset-0 flex flex-col items-center justify-center bg-white/70 dark:bg-gray-900/70 px-6 text-center">
<p className="text-sm sm:text-base font-medium text-gray-900 dark:text-gray-100">
AI section locked. Enable any AI subscription to reveal
today&apos;s pick.
{getAiSubscriptionGateMessage(pricingTier ?? undefined)}
</p>
<Link
href="/pricing"
Expand Down
35 changes: 33 additions & 2 deletions components/UserProfileMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ const PROVIDER_OPTIONS: Array<{
{
id: "OLLAMA",
name: "Ollama (Local)",
subtitle: "Run locally on your machine",
allowedTiers: ["LOCAL"],
subtitle: "Run on your machine (no provider API key)",
allowedTiers: ["LOCAL", "BYOK"],
},
{
id: "OPENAI",
Expand Down Expand Up @@ -161,6 +161,30 @@ export function UserProfileMenu() {
}
}, []);

useEffect(() => {
const allowedForTier = PROVIDER_OPTIONS.filter((p) =>
p.allowedTiers.includes(tier)
);
if (allowedForTier.length === 0) {
if (typeof window !== "undefined") {
localStorage.removeItem("explanations_provider");
}
setSelectedExplanationProvider((prev) =>
prev === "OPENAI" ? prev : "OPENAI"
);
return;
}
const currentOk = allowedForTier.some(
(p) => p.id === selectedExplanationProvider
);
if (currentOk) return;
const next = allowedForTier[0].id;
setSelectedExplanationProvider(next);
if (typeof window !== "undefined") {
localStorage.setItem("explanations_provider", next);
}
}, [tier, selectedExplanationProvider]);

useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
Expand Down Expand Up @@ -331,6 +355,13 @@ export function UserProfileMenu() {
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Explanations Provider
</h3>
{!["LOCAL", "BYOK", "HOSTED_AI"].includes(tier) && (
<p className="text-xs text-amber-700 dark:text-amber-300/90">
AI explanations (Ollama, cloud keys, or Ditectrev AI) unlock
after you subscribe to an AI plan — Ads-free and Free tiers do
not include server-side AI.
</p>
)}
<div className="grid grid-cols-2 gap-2">
{PROVIDER_OPTIONS.map((provider) => {
const allowed = provider.allowedTiers.includes(tier);
Expand Down
Loading
Loading