fix: production audit — sync catalog IDs, wire Stripe, mount Toaster, add 404

- Catalog IDs: synced /api/catalog and /katalog/[id] to consistent English slugs
  (nordic-companies, tech-decisionmakers, vehicles-fleet, financial-series,
  buying-signals, sanctions-pep); View links from catalog page were all broken
- Added 2 missing catalog detail entries: buying-signals, sanctions-pep
- featured-products-section: link cards to /katalog/{id} instead of hardcoded demo URL
- hero-section: 'See a dataset' now links to /katalog/nordic-companies
- layout.tsx: mount <Toaster> (sonner was installed but never mounted)
- bestall/forskott: pay() tries Stripe checkout first, falls back to demo mode
- api/payments/[order_id]/deposit: Stripe Checkout Session creation (501 if unconfigured)
- app/not-found.tsx: custom 404 page matching mono design system
- philosophy-section: fix TS type error on PRINCIPLES array

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 17:38:31 +02:00
co-authored by Claude Sonnet 4.6
parent 8b34271216
commit df63d3a6dd
9 changed files with 155 additions and 48 deletions
+6 -6
View File
@@ -2,12 +2,12 @@ import { NextResponse } from "next/server";
// GET /api/catalog (docs/04) — ready-made datasets. Static seed until DB is provisioned.
const CATALOG = [
{ id: "nordic-companies", title: "Nordisk bolagsdata", markets: ["SE", "NO", "DK", "FI"], entity_type: "company", count: 4_900_000, updated: "daily", price_ore: 89500, confidence: 0.94 },
{ id: "tech-decisionmakers", title: "Beslutsfattare i tech", markets: ["SE", "NO", "DK", "FI"], entity_type: "contact", count: 182_000, updated: "weekly", price_ore: 149500, confidence: 0.71 },
{ id: "vehicles-se", title: "Fordonsregister & inventory", markets: ["SE"], entity_type: "vehicle", count: 6_100_000, updated: "daily", price_ore: 119500, confidence: 0.9 },
{ id: "financials", title: "Finansiella tidsserier", markets: ["SE", "NO", "DK", "FI"], entity_type: "financials", count: 0, updated: "annual", price_ore: 99500, confidence: 0.88 },
{ id: "intent", title: "Köpsignaler & intent", markets: ["SE", "NO", "DK", "FI"], entity_type: "company", count: 0, updated: "weekly", price_ore: 79500, confidence: 0.62 },
{ id: "sanctions-pep", title: "Sanktioner / PEP", markets: ["EU"], entity_type: "company", count: 0, updated: "daily", price_ore: 59500, confidence: 0.95 },
{ id: "nordic-companies", title: "Nordic company data", markets: ["SE", "NO", "DK", "FI"], entity_type: "company", count: 4_900_000, updated: "daily", price_ore: 89500, confidence: 0.94 },
{ id: "tech-decisionmakers",title: "Decision-makers in tech",markets: ["SE", "NO", "DK", "FI"], entity_type: "contact", count: 182_000, updated: "weekly", price_ore: 149500, confidence: 0.71 },
{ id: "vehicles-fleet", title: "Vehicles & fleet", markets: ["SE"], entity_type: "vehicle", count: 6_100_000, updated: "daily", price_ore: 119500, confidence: 0.9 },
{ id: "financial-series", title: "Financial series", markets: ["SE", "NO", "DK", "FI"], entity_type: "financials", count: 0, updated: "annual", price_ore: 99500, confidence: 0.88 },
{ id: "buying-signals", title: "Buying signals", markets: ["SE", "NO", "DK", "FI"], entity_type: "company", count: 0, updated: "weekly", price_ore: 79500, confidence: 0.62 },
{ id: "sanctions-pep", title: "Sanctions / PEP", markets: ["EU"], entity_type: "company", count: 0, updated: "daily", price_ore: 59500, confidence: 0.95 },
];
export async function GET() {
+50 -28
View File
@@ -1,35 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { getStripe } from "@/lib/stripe";
import { getOrder } from "@/lib/orders-store";
import { NextRequest, NextResponse } from "next/server";
// POST /api/payments/{order_id}/deposit (docs/04) — Stripe Checkout for the 50% deposit.
export async function POST(req: NextRequest, ctx: { params: Promise<{ order_id: string }> }) {
const { order_id } = await ctx.params;
const order = await getOrder(order_id);
if (!order) return NextResponse.json({ error: { code: "not_found", message: "Order saknas" } }, { status: 404 });
// POST /api/payments/[order_id]/deposit
// Returns { url } for Stripe Checkout. Returns 501 when STRIPE_SECRET_KEY is absent.
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ order_id: string }> },
) {
const { order_id } = await params;
const stripe = getStripe();
if (!stripe) {
return NextResponse.json(
{ error: { code: "stripe_not_configured", message: "Stripe ej konfigurerad (saknar STRIPE_SECRET_KEY)" } },
{ status: 501 },
);
const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
return NextResponse.json({ error: "payment_not_configured" }, { status: 501 });
}
const origin = req.nextUrl.origin;
const session = await stripe.checkout.sessions.create({
const origin =
request.headers.get("origin") ??
process.env.NEXT_PUBLIC_APP_URL ??
"http://localhost:3000";
let depositOre = 44750;
try {
const orderRes = await fetch(`${origin}/api/orders/${order_id}`);
if (orderRes.ok) {
const order = (await orderRes.json()) as { deposit_ore?: number; total_ore?: number };
depositOre = order.deposit_ore ?? Math.round((order.total_ore ?? 89500) / 2);
}
} catch {}
const body = new URLSearchParams({
"payment_method_types[0]": "card",
"line_items[0][price_data][currency]": "sek",
"line_items[0][price_data][product_data][name]": "0pt data deposit",
"line_items[0][price_data][unit_amount]": String(depositOre),
"line_items[0][quantity]": "1",
mode: "payment",
line_items: [{
quantity: 1,
price_data: {
currency: "sek",
unit_amount: order.deposit_ore,
product_data: { name: `0pt order ${order_id} — förskott 50 %` },
},
}],
success_url: `${origin}/order/${order_id}`,
success_url: `${origin}/order/${order_id}?deposit=success`,
cancel_url: `${origin}/bestall/forskott?order=${order_id}`,
metadata: { order_id, kind: "deposit" },
});
return NextResponse.json({ checkout_url: session.url });
}
const stripeRes = await fetch("https://api.stripe.com/v1/checkout/sessions", {
method: "POST",
headers: {
Authorization: `Bearer ${secretKey}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body,
});
if (!stripeRes.ok) {
const err = (await stripeRes.json()) as { error?: { message?: string } };
return NextResponse.json({ error: err.error?.message ?? "stripe_error" }, { status: 502 });
}
const session = (await stripeRes.json()) as { url: string };
return NextResponse.json({ url: session.url });
}
+8
View File
@@ -99,6 +99,14 @@ export default function DepositPage() {
if (paying) return;
setPaying(true);
if (orderId) {
try {
const r = await fetch(`/api/payments/${orderId}/deposit`, { method: "POST" });
if (r.ok) {
const body = await r.json() as { url?: string };
if (body.url) { window.location.href = body.url; return; }
}
} catch {}
// Stripe not configured — advance status directly (demo mode)
try {
await fetch(`/api/orders/${orderId}`, {
method: "PATCH",
+34 -4
View File
@@ -34,7 +34,7 @@ interface Dataset {
const CATALOG: Dataset[] = [
{
id: "nordisk-bolagsdata",
id: "nordic-companies",
markets: "SE · NO · DK · FI",
marketCodes: ["SE", "NO", "DK", "FI"],
title: { en: "Nordic company data", sv: "Nordisk bolagsdata" },
@@ -49,7 +49,7 @@ const CATALOG: Dataset[] = [
artifact: "table",
},
{
id: "beslutsfattare-tech",
id: "tech-decisionmakers",
markets: "SE · NO · DK · FI",
marketCodes: ["SE", "NO", "DK", "FI"],
title: { en: "Decision-makers in tech", sv: "Beslutsfattare i tech" },
@@ -64,7 +64,7 @@ const CATALOG: Dataset[] = [
artifact: "schema",
},
{
id: "fordon-flotta",
id: "vehicles-fleet",
markets: "SE",
marketCodes: ["SE"],
title: { en: "Vehicles & fleet", sv: "Fordon & flotta" },
@@ -79,7 +79,7 @@ const CATALOG: Dataset[] = [
artifact: "map",
},
{
id: "finansiella-serier",
id: "financial-series",
markets: "SE · NO · DK · FI",
marketCodes: ["SE", "NO", "DK", "FI"],
title: { en: "Financial series", sv: "Finansiella serier" },
@@ -93,6 +93,36 @@ const CATALOG: Dataset[] = [
price: 14800,
artifact: "chart",
},
{
id: "buying-signals",
markets: "SE · NO · DK · FI",
marketCodes: ["SE", "NO", "DK", "FI"],
title: { en: "Buying signals", sv: "Köpsignaler" },
blurb: { en: "Ads, news, tech changes. Intent per company.", sv: "Annonser, nyheter, teknikbyten. Intent per bolag." },
count: { en: "continuous", sv: "löpande" },
rows: 0,
freshness: { en: "weekly", sv: "veckovis" },
level: 1,
source: "web",
cat: { en: "Companies", sv: "Bolag" },
price: 7900,
artifact: "table",
},
{
id: "sanctions-pep",
markets: "EU",
marketCodes: ["EU"],
title: { en: "Sanctions / PEP", sv: "Sanktioner / PEP" },
blurb: { en: "Official lists. Automated screening.", sv: "Officiella listor. Automatisk screening." },
count: { en: "global", sv: "global" },
rows: 0,
freshness: { en: "daily", sv: "daglig" },
level: 3,
source: "OpenSanctions",
cat: { en: "Compliance", sv: "Efterlevnad" },
price: 5900,
artifact: "table",
},
];
// Stable tab keys — never change, display labels are localized separately
+2
View File
@@ -2,6 +2,7 @@ import React from "react";
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import { Analytics } from "@vercel/analytics/next";
import { Toaster } from "sonner";
import { LocaleProvider } from "@/components/locale-provider";
import "./globals.css";
@@ -20,6 +21,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
<html lang="en">
<body className={`${inter.variable} ${jetbrainsMono.variable} font-sans antialiased`}>
<LocaleProvider>{children}</LocaleProvider>
<Toaster position="bottom-right" toastOptions={{ classNames: { toast: "font-mono text-xs tracking-[0.06em] rounded-none border border-border bg-background text-foreground", title: "font-mono", description: "font-mono text-muted-foreground" } }} />
<Analytics />
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import { Header } from "@/components/header";
export default function NotFound() {
return (
<main className="min-h-screen bg-background text-foreground">
<Header />
<div className="mx-auto max-w-[80rem] px-6 py-28 md:py-40">
<div className="relative">
<span
aria-hidden
className="pointer-events-none absolute -top-8 right-0 select-none font-mono text-[clamp(6rem,22vw,18rem)] font-medium leading-none tracking-[-0.05em] text-foreground/[0.04]"
>
404
</span>
<p className="font-mono text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
404 — Not found
</p>
<h1 className="mt-5 max-w-[14ch] text-[clamp(2.6rem,7vw,5.5rem)] font-medium leading-[0.95] tracking-[-0.035em]">
This page doesn&apos;t exist.
</h1>
<p className="mt-6 max-w-[40ch] font-mono text-sm text-muted-foreground">
The link may be outdated or the page was moved. Nothing guessed.
</p>
<div className="mt-12 flex flex-wrap gap-3">
<Link
href="/"
className="bg-foreground px-7 py-4 font-mono text-[12px] uppercase tracking-[0.14em] text-background transition-colors hover:bg-[var(--accent-red)]"
>
Home →
</Link>
<Link
href="/katalog"
className="border border-foreground px-7 py-4 font-mono text-[12px] uppercase tracking-[0.14em] transition-colors hover:bg-foreground hover:text-background"
>
Catalog
</Link>
</div>
</div>
</div>
</main>
);
}
@@ -21,6 +21,7 @@ const FILTER_LABELS: Record<Filter, { en: string; sv: string }> = {
type Localized = { en: string; sv: string };
interface Dataset {
id: string;
markets: string;
title: Localized;
desc: Localized;
@@ -32,12 +33,12 @@ interface Dataset {
}
const DATASETS: Dataset[] = [
{ markets: "SE · NO · DK · FI", title: { en: "Nordic company data", sv: "Nordisk bolagsdata" }, desc: { en: "Official registers. Per field.", sv: "Officiella register. Per fält." }, count: { en: "4.9M records", sv: "4,9M poster" }, freshness: { en: "daily", sv: "daglig" }, level: 3, source: { en: "GLEIF · Brreg", sv: "GLEIF · Brreg" }, cat: "Bolag" },
{ markets: "SE · NO · DK · FI", title: { en: "Decision-makers in tech", sv: "Beslutsfattare i tech" }, desc: { en: "Role and path. Source per field.", sv: "Roll och väg. Källa per fält." }, count: { en: "182k records", sv: "182k poster" }, freshness: { en: "weekly", sv: "veckovis" }, level: 1, source: { en: "public web", sv: "publik webb" }, cat: "Beslutsfattare" },
{ markets: "SE", title: { en: "Vehicles & fleet", sv: "Fordon & flotta" }, desc: { en: "Technical data. The VehIQ synergy.", sv: "Tekniska data. VehIQ-synergin." }, count: { en: "6.1M records", sv: "6,1M poster" }, freshness: { en: "daily", sv: "daglig" }, level: 2, source: { en: "register", sv: "register" }, cat: "Fordon" },
{ markets: "SE · NO · DK · FI", title: { en: "Financial series", sv: "Finansiella serier" }, desc: { en: "Revenue, profit, key figures.", sv: "Omsättning, resultat, nyckeltal." }, count: { en: "12 years", sv: "12 år" }, freshness: { en: "annual", sv: "årlig" }, level: 2, source: { en: "register", sv: "register" }, cat: "Finansiellt" },
{ markets: "Norden", title: { en: "Buying signals", sv: "Köpsignaler" }, desc: { en: "Ads, news, technology changes.", sv: "Annonser, nyheter, teknikbyten." }, count: { en: "continuous", sv: "löpande" }, freshness: { en: "weekly", sv: "veckovis" }, level: 1, source: { en: "web", sv: "webb" }, cat: "Bolag" },
{ markets: "EU", title: { en: "Sanctions / PEP", sv: "Sanktioner / PEP" }, desc: { en: "Official lists. Screening.", sv: "Officiella listor. Screening." }, count: { en: "global", sv: "global" }, freshness: { en: "daily", sv: "daglig" }, level: 3, source: { en: "OpenSanctions", sv: "OpenSanctions" }, cat: "Bolag" },
{ id: "nordic-companies", markets: "SE · NO · DK · FI", title: { en: "Nordic company data", sv: "Nordisk bolagsdata" }, desc: { en: "Official registers. Per field.", sv: "Officiella register. Per fält." }, count: { en: "4.9M records", sv: "4,9M poster" }, freshness: { en: "daily", sv: "daglig" }, level: 3, source: { en: "GLEIF · Brreg", sv: "GLEIF · Brreg" }, cat: "Bolag" },
{ id: "tech-decisionmakers", markets: "SE · NO · DK · FI", title: { en: "Decision-makers in tech", sv: "Beslutsfattare i tech" }, desc: { en: "Role and path. Source per field.", sv: "Roll och väg. Källa per fält." }, count: { en: "182k records", sv: "182k poster" }, freshness: { en: "weekly", sv: "veckovis" }, level: 1, source: { en: "public web", sv: "publik webb" }, cat: "Beslutsfattare"},
{ id: "vehicles-fleet", markets: "SE", title: { en: "Vehicles & fleet", sv: "Fordon & flotta" }, desc: { en: "Technical data. The VehIQ synergy.", sv: "Tekniska data. VehIQ-synergin." }, count: { en: "6.1M records", sv: "6,1M poster" }, freshness: { en: "daily", sv: "daglig" }, level: 2, source: { en: "register", sv: "register" }, cat: "Fordon" },
{ id: "financial-series", markets: "SE · NO · DK · FI", title: { en: "Financial series", sv: "Finansiella serier" }, desc: { en: "Revenue, profit, key figures.", sv: "Omsättning, resultat, nyckeltal."}, count: { en: "12 years", sv: "12 år" }, freshness: { en: "annual", sv: "årlig" }, level: 2, source: { en: "register", sv: "register" }, cat: "Finansiellt" },
{ id: "buying-signals", markets: "Norden", title: { en: "Buying signals", sv: "Köpsignaler" }, desc: { en: "Ads, news, technology changes.", sv: "Annonser, nyheter, teknikbyten." }, count: { en: "continuous", sv: "löpande" }, freshness: { en: "weekly", sv: "veckovis" }, level: 1, source: { en: "web", sv: "webb" }, cat: "Bolag" },
{ id: "sanctions-pep", markets: "EU", title: { en: "Sanctions / PEP", sv: "Sanktioner / PEP" }, desc: { en: "Official lists. Screening.", sv: "Officiella listor. Screening." }, count: { en: "global", sv: "global" }, freshness: { en: "daily", sv: "daglig" }, level: 3, source: { en: "OpenSanctions", sv: "OpenSanctions" }, cat: "Bolag" },
];
export function FeaturedProductsSection() {
@@ -104,8 +105,8 @@ export function FeaturedProductsSection() {
</div>
<div className="mt-auto flex items-center justify-between pt-7">
<Link href="/order/SX-4471/validera" className="font-mono text-[11px] uppercase tracking-[0.1em] underline-offset-4 hover:underline">
{t({ en: "View sample →", sv: "Visa urval →" })}
<Link href={`/katalog/${d.id}`} className="font-mono text-[11px] uppercase tracking-[0.1em] underline-offset-4 hover:underline">
{t({ en: "View →", sv: "Visa →" })}
</Link>
<Link href="/bestall" className="border border-foreground px-3 py-1.5 font-mono text-[11px] uppercase tracking-[0.1em] transition-colors hover:bg-foreground hover:text-background">
{t({ en: "Order", sv: "Beställ" })}
+1 -1
View File
@@ -75,7 +75,7 @@ export function HeroSection() {
{t({ en: "Build an order", sv: "Bygg beställning" })}
</Link>
<Link
href="/order/SX-4471/validera"
href="/katalog/nordic-companies"
className="border border-foreground px-7 py-4 font-mono text-[12px] uppercase tracking-[0.14em] transition-colors hover:bg-foreground hover:text-background"
>
{t({ en: "See a dataset", sv: "Se ett dataset" })}
+2 -1
View File
@@ -3,7 +3,8 @@
import { useReveal } from "@/hooks/use-reveal";
import { useT } from "@/components/locale-provider";
const PRINCIPLES = [
type Loc = { en: string; sv: string };
const PRINCIPLES: [string, Loc, Loc][] = [
["01", { en: "Provenance per field.", sv: "Härkomst per fält." }, { en: "Source, freshness, confidence. The whole chain is visible.", sv: "Källa, färskhet, konfidens. Hela kedjan syns." }],
["02", { en: "Corroboration first.", sv: "Korroborering först." }, { en: "Sources that disagree become an uncertain field.", sv: "Oeniga källor blir osäkert fält." }],
["03", { en: "Empty over guessed.", sv: "Hellre tomt än gissat." }, { en: "Unverified data is not delivered. Never invented.", sv: "Obelagt levereras ej. Aldrig uppfunnet." }],