diff --git a/apps/web/src/app/api/public/pricing/estimate/route.ts b/apps/web/src/app/api/public/pricing/estimate/route.ts new file mode 100644 index 000000000..bff2993a9 --- /dev/null +++ b/apps/web/src/app/api/public/pricing/estimate/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { estimatePublicPricing } from "@/lib/public-pricing"; +import { rateLimit } from "@/lib/rate-limit"; +import { hasTrustedMutationOrigin } from "@/lib/request-security"; + +const limiter = rateLimit({ + max: 30, + windowMs: 60_000, + prefix: "public-pricing", +}); +const schema = z + .object({ + id: z.string().min(1).max(128), + version: z.string().min(1).max(128), + units: z.number().int().min(1).max(100000).optional(), + promptTokens: z.number().int().min(0).max(1048576).optional(), + completionTokens: z.number().int().min(0).max(1048576).optional(), + cachedPromptTokens: z.number().int().min(0).max(1048576).optional(), + }) + .strict(); + +export async function POST(request: Request) { + if (!hasTrustedMutationOrigin(request)) + return NextResponse.json({ error: "Origin not allowed." }, { status: 403 }); + const limited = await limiter.check( + request.headers.get("cf-connecting-ip") ?? "public", + ); + if (limited) return limited; + const text = await request.text(); + if (text.length > 1024) + return NextResponse.json({ error: "Request too large." }, { status: 413 }); + let data: unknown; + try { + data = JSON.parse(text); + } catch { + return NextResponse.json({ error: "Invalid JSON." }, { status: 400 }); + } + const parsed = schema.safeParse(data); + if (!parsed.success) + return NextResponse.json( + { error: "Invalid estimate parameters." }, + { status: 400 }, + ); + try { + const quote = await estimatePublicPricing(parsed.data); + return NextResponse.json(quote, { + headers: { "Cache-Control": "no-store" }, + }); + } catch (error) { + if (error instanceof RangeError) + return NextResponse.json({ error: error.message }, { status: 422 }); + return NextResponse.json( + { error: "Estimate temporarily unavailable." }, + { status: 503 }, + ); + } +} diff --git a/apps/web/src/app/api/public/pricing/route.ts b/apps/web/src/app/api/public/pricing/route.ts new file mode 100644 index 000000000..8dbee5ac5 --- /dev/null +++ b/apps/web/src/app/api/public/pricing/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { getPublicPricingCatalogue } from "@/lib/public-pricing"; + +export async function GET() { + try { + const catalogue = await getPublicPricingCatalogue(); + return NextResponse.json(catalogue, { + headers: { + "Cache-Control": "public, s-maxage=60, stale-while-revalidate=120", + }, + }); + } catch { + return NextResponse.json( + { error: "Pricing is temporarily unavailable. Please try again." }, + { status: 503, headers: { "Cache-Control": "no-store" } }, + ); + } +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index b18575c20..10f7ba02c 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -189,6 +189,7 @@ export default function HomePage() { ["#models", "Models"], ["#workflow", "Workflow"], ["#teams", "For teams"], + ["/pricing", "Pricing"], ["/learn", "Learn"], ].map(([href, label]) => (
+ + Pricing + Learn diff --git a/apps/web/src/app/pricing/page.tsx b/apps/web/src/app/pricing/page.tsx new file mode 100644 index 000000000..59e9aabea --- /dev/null +++ b/apps/web/src/app/pricing/page.tsx @@ -0,0 +1,258 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { Button } from "@aiwa/ui/button"; +import { Brand } from "@/components/ui/brand"; +import { Icon } from "@/components/ui/icon"; +import { ThemeToggle } from "@/components/theme/theme-toggle"; +import { PricingArtwork } from "@/components/marketing/pricing-artwork"; +import { PricingExplorer } from "@/components/marketing/pricing-explorer"; +import { getPublicPricingCatalogue } from "@/lib/public-pricing"; + +export const dynamic = "force-dynamic"; +export const metadata: Metadata = { + title: "AI model pricing: images, video, voice, chat & media tools", + description: + "Explore live Aiwa Creators customer pricing by model and task. Compare image, video, chat, speech, transcription and MediaKit tools in credits and OMR.", + alternates: { canonical: "/pricing" }, + robots: { index: true, follow: true }, + openGraph: { + title: "Clear AI model pricing · Aiwa Creators", + description: + "Explore model capabilities, billing units, credits and OMR before you create.", + type: "website", + }, +}; + +const questions = [ + { + question: "What is one credit worth?", + answer: + "The standard denomination for newly published prices is one credit per baisa: 1,000 credits correspond to 1.000 OMR. An individual model's published price version remains authoritative, including any historic denomination.", + }, + { + question: "Why do some models show reference rates?", + answer: + "Resolution, input assets, additional references, duration and provider-reported usage can affect a generation's final cost. We show a published reference unit, not a promise that every configuration costs the same.", + }, + { + question: "When am I charged?", + answer: + "The workspace displays an authenticated quote before submission. Applicable credits may be reserved; successful jobs settle according to the published policy, and unused reservations are released where supported.", + }, + { + question: "Can I compare free and paid text models?", + answer: + "Only currently enabled models with published customer pricing and configured provider access appear here. Token-rate tiers display the customer retail equivalent for each applicable tier.", + }, + { + question: "Can I generate directly from this page?", + answer: + "No. This public page does not submit jobs or access an organization wallet. Sign in to see the exact parameters, signed quote and available credit balance before you proceed.", + }, +] as const; + +export default async function PricingPage() { + const catalogue = await getPublicPricingCatalogue(); + return ( +
+
+ ); +} diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts index 4731e84b3..0aba425d2 100644 --- a/apps/web/src/app/sitemap.ts +++ b/apps/web/src/app/sitemap.ts @@ -12,6 +12,7 @@ export default async function sitemap(): Promise { }); return [ { url: origin }, + { url: `${origin}/pricing` }, { url: `${origin}/learn` }, ...posts .filter((p) => !publishedContent(p.published).noindex) diff --git a/apps/web/src/components/marketing/pricing-artwork.tsx b/apps/web/src/components/marketing/pricing-artwork.tsx new file mode 100644 index 000000000..fa32eabcd --- /dev/null +++ b/apps/web/src/components/marketing/pricing-artwork.tsx @@ -0,0 +1,198 @@ +import { Icon } from "@/components/ui/icon"; + +/** Token-driven inline SVG artwork: no third-party assets, remote calls or image hydration. */ +export function PricingArtwork() { + return ( +
+