Skapat abonnemangsflöde

X-Lovable-Edit-ID: edt-825926b7-8451-4268-900d-95b2863865db
Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-07-11 13:00:19 +00:00
9 changed files with 492 additions and 34 deletions
+2 -2
View File
@@ -12,14 +12,14 @@ import AccountConfirmation from "./pages/AccountConfirmation";
import Contact from "./pages/Contact";
import Admin from "./pages/Admin";
import FAQ from "./pages/FAQ";
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
const queryClient = new QueryClient();
function AppContent() {
return (
<BrowserRouter>
<PaymentTestModeBanner />
<Routes>
<Route path="/" element={<Index />} />
<Route path="/contact" element={<Contact />} />
+19
View File
@@ -0,0 +1,19 @@
const clientToken = import.meta.env.VITE_PAYMENTS_CLIENT_TOKEN as string | undefined;
export function PaymentTestModeBanner() {
if (!clientToken) {
return (
<div className="w-full bg-red-100 border-b border-red-300 px-4 py-2 text-center text-sm text-red-800">
Skarpa betalningar är inte konfigurerade. Slutför go-live i Payments-fliken.
</div>
);
}
if (clientToken.startsWith("pk_test_")) {
return (
<div className="w-full bg-orange-100 border-b border-orange-300 px-4 py-2 text-center text-sm text-orange-800">
Alla betalningar i förhandsvisningen är i testläge. Använd kort <strong>4242 4242 4242 4242</strong>, valfritt framtida datum och CVC.
</div>
);
}
return null;
}
+128 -32
View File
@@ -1,12 +1,15 @@
import { useState, useMemo, useEffect } from "react";
import { Loader2, CheckCircle2 } from "lucide-react";
import { Loader2, CheckCircle2, CreditCard, FileText } from "lucide-react";
import { supabase } from "@/integrations/supabase/client";
import { useAuth } from "@/hooks/useAuth";
import { toast } from "sonner";
import { z } from "zod";
import { StripeEmbeddedCheckout } from "@/components/StripeEmbeddedCheckout";
import { getStripeEnvironment, hasPaymentsConfigured } from "@/lib/stripe";
const PRICE_PER_KG = 325;
const KG_PER_EMPLOYEE = 0.3;
const WEEKS_PER_MONTH = 4;
const isStockholmPostalCode = (postalCode: string) => {
const cleaned = postalCode.replace(/\s/g, "");
@@ -29,13 +32,17 @@ const formSchema = z.object({
kommentarer: z.string().trim().max(1000).optional(),
});
type Method = "card" | "invoice";
const PlansSection = () => {
const { user } = useAuth();
const [employees, setEmployees] = useState(10);
const [showForm, setShowForm] = useState(false);
const [method, setMethod] = useState<Method>("card");
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [submitted, setSubmitted] = useState<null | "card" | "invoice">(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [form, setForm] = useState({
företag: "",
epost: "",
@@ -51,8 +58,8 @@ const PlansSection = () => {
[employees]
);
const weeklyPrice = recommendedKg * PRICE_PER_KG;
const monthlyPrice = weeklyPrice * WEEKS_PER_MONTH;
// Prefyll från profil om inloggad
useEffect(() => {
if (!user || !showForm) return;
(async () => {
@@ -93,31 +100,79 @@ const PlansSection = () => {
return;
}
if (!hasPaymentsConfigured()) {
toast.error("Betalningar är inte konfigurerade ännu.");
return;
}
setSubmitting(true);
try {
const { data, error } = await supabase.functions.invoke("submit-order", {
body: {
...result.data,
employees,
kg_per_vecka: recommendedKg,
pris_per_vecka: weeklyPrice,
},
});
const body = {
method,
employees,
kg_per_week: recommendedKg,
company: result.data.företag,
email: result.data.epost,
phone: result.data.telefon ?? "",
address: result.data.adress ?? "",
postal_code: result.data.postnummer,
city: result.data.stad ?? "",
comments: result.data.kommentarer ?? "",
return_url: `${window.location.origin}/?checkout=success&session_id={CHECKOUT_SESSION_ID}`,
environment: getStripeEnvironment(),
};
const { data, error } = await supabase.functions.invoke("create-checkout", { body });
if (error) throw error;
if ((data as any)?.error) throw new Error((data as any).error);
setSubmitted(true);
} catch (err) {
if (method === "card") {
if (!(data as any)?.clientSecret) throw new Error("Kunde inte skapa checkout-session");
setClientSecret((data as any).clientSecret);
} else {
setSubmitted("invoice");
}
} catch (err: any) {
console.error(err);
toast.error("Kunde inte skicka beställningen. Försök igen.");
toast.error(err?.message || "Kunde inte starta abonnemang. Försök igen.");
} finally {
setSubmitting(false);
}
};
// Show embedded checkout after we get clientSecret
if (clientSecret) {
return (
<section id="abonnemang" className="py-16 md:py-28 px-6 bg-background">
<div className="max-w-3xl mx-auto">
<div className="text-center mb-8">
<div className="w-24 h-px bg-primary/40 mx-auto mb-6"></div>
<h2 className="font-display text-3xl md:text-5xl font-semibold mb-2">
Slutför betalning
</h2>
<p className="text-muted-foreground text-sm">
{recommendedKg} kg/vecka · {monthlyPrice.toLocaleString("sv-SE")} kr/månad (exkl. moms)
</p>
</div>
<div className="bg-card border border-border rounded-sm p-4 md:p-6">
<StripeEmbeddedCheckout fetchClientSecret={async () => clientSecret} />
</div>
<div className="text-center mt-4">
<button
onClick={() => setClientSecret(null)}
className="text-sm text-muted-foreground underline hover:text-foreground"
>
Avbryt och tillbaka
</button>
</div>
</div>
</section>
);
}
return (
<section id="abonnemang" className="py-16 md:py-28 px-6 bg-background">
<div className="max-w-3xl mx-auto">
{/* Header */}
<div className="text-center mb-10 md:mb-14">
<div className="w-24 h-px bg-primary/40 mx-auto mb-6"></div>
<h2 className="font-display text-3xl md:text-5xl font-semibold mb-4">
@@ -128,25 +183,23 @@ const PlansSection = () => {
</p>
</div>
{/* Calculator card */}
<div className="bg-card border border-border rounded-sm shadow-[0_10px_40px_-15px_rgba(0,0,0,0.15)] p-6 md:p-12">
{submitted ? (
{submitted === "invoice" ? (
<div className="text-center py-8 animate-fade-in">
<CheckCircle2 className="w-16 h-16 text-primary mx-auto mb-4" />
<h3 className="font-display text-3xl md:text-4xl font-semibold mb-3">
Tack för din beställning!
Abonnemang startat!
</h3>
<p className="text-muted-foreground max-w-md mx-auto">
Vi har tagit emot din förfrågan för{" "}
Vi har skapat ditt abonnemang {" "}
<strong className="text-foreground">
{recommendedKg} kg kakor per vecka
</strong>{" "}
och kontaktar dig inom kort för att bekräfta leverans.
</strong>
. Första fakturan skickas till <strong>{form.epost}</strong> inom kort.
</p>
</div>
) : !showForm ? (
<>
{/* Employee count */}
<div className="text-center mb-8">
<div
key={employees}
@@ -159,7 +212,6 @@ const PlansSection = () => {
</div>
</div>
{/* Slider */}
<div className="mb-10 md:mb-12">
<input
type="range"
@@ -177,7 +229,6 @@ const PlansSection = () => {
</div>
</div>
{/* Result */}
<div className="text-center border-t border-border pt-8 md:pt-10 space-y-6">
<div>
<div className="text-muted-foreground text-xs md:text-sm uppercase tracking-widest mb-2">
@@ -191,12 +242,14 @@ const PlansSection = () => {
<div>
<div className="text-muted-foreground text-xs md:text-sm uppercase tracking-widest mb-2">
Pris per vecka
Pris per månad
</div>
<div className="font-display text-3xl md:text-4xl font-semibold text-primary tabular-nums">
{weeklyPrice.toLocaleString("sv-SE")} kr
{monthlyPrice.toLocaleString("sv-SE")} kr
</div>
<div className="text-muted-foreground text-xs mt-1">
exklusive moms · {weeklyPrice.toLocaleString("sv-SE")} kr/vecka
</div>
<div className="text-muted-foreground text-xs mt-1">exklusive moms</div>
</div>
<button
@@ -211,12 +264,12 @@ const PlansSection = () => {
<form onSubmit={handleSubmit} className="space-y-5 animate-fade-in">
<div className="text-center mb-2">
<div className="text-muted-foreground text-xs uppercase tracking-widest mb-1">
Din beställning
Ditt abonnemang
</div>
<div className="font-display text-2xl md:text-3xl font-semibold">
{recommendedKg} kg/vecka {" "}
<span className="text-primary">
{weeklyPrice.toLocaleString("sv-SE")} kr
{monthlyPrice.toLocaleString("sv-SE")} kr/mån
</span>
</div>
<div className="text-muted-foreground text-xs">
@@ -224,6 +277,47 @@ const PlansSection = () => {
</div>
</div>
{/* Betalningsmetod */}
<div>
<label className="block mb-2 text-sm font-semibold">Betalningsmetod</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button
type="button"
onClick={() => setMethod("card")}
className={`flex items-center gap-3 p-4 rounded-sm border text-left transition-colors ${
method === "card"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
>
<CreditCard className="w-5 h-5 text-primary" />
<div>
<div className="font-semibold text-sm">Kort</div>
<div className="text-xs text-muted-foreground">
Automatiskt varje månad
</div>
</div>
</button>
<button
type="button"
onClick={() => setMethod("invoice")}
className={`flex items-center gap-3 p-4 rounded-sm border text-left transition-colors ${
method === "invoice"
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50"
}`}
>
<FileText className="w-5 h-5 text-primary" />
<div>
<div className="font-semibold text-sm">Faktura</div>
<div className="text-xs text-muted-foreground">
Månadsvis via e-post
</div>
</div>
</button>
</div>
</div>
<div>
<label className="block mb-1.5 text-sm font-semibold">
Företagsnamn <span className="text-primary">*</span>
@@ -332,15 +426,17 @@ const PlansSection = () => {
{submitting ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Skickar...
{method === "card" ? "Öppnar betalning..." : "Startar abonnemang..."}
</>
) : method === "card" ? (
"Fortsätt till betalning"
) : (
"Skicka beställning"
"Starta abonnemang (faktura)"
)}
</button>
</div>
<p className="text-center text-muted-foreground text-xs">
Vi kontaktar dig för att bekräfta leverans och betalning.
Alla priser exklusive moms. Moms (25%) läggs vid betalning.
</p>
</form>
)}
+16
View File
@@ -0,0 +1,16 @@
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from "@stripe/react-stripe-js";
import { getStripe } from "@/lib/stripe";
interface Props {
fetchClientSecret: () => Promise<string>;
}
export function StripeEmbeddedCheckout({ fetchClientSecret }: Props) {
return (
<div id="checkout">
<EmbeddedCheckoutProvider stripe={getStripe()} options={{ fetchClientSecret }}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { loadStripe, Stripe } from "@stripe/stripe-js";
export type StripeEnv = "sandbox" | "live";
const clientToken = import.meta.env.VITE_PAYMENTS_CLIENT_TOKEN as string | undefined;
function paymentsEnvironment(): StripeEnv {
if (clientToken?.startsWith("pk_test_")) return "sandbox";
if (clientToken?.startsWith("pk_live_")) return "live";
throw new Error(
"Betalningar är inte konfigurerade för denna build. Slutför go-live i Payments-fliken för att aktivera skarpa betalningar."
);
}
let stripePromise: Promise<Stripe | null> | null = null;
export function getStripe(): Promise<Stripe | null> {
if (!stripePromise) {
paymentsEnvironment();
stripePromise = loadStripe(clientToken as string);
}
return stripePromise;
}
export function getStripeEnvironment(): StripeEnv {
return paymentsEnvironment();
}
export function hasPaymentsConfigured(): boolean {
return !!clientToken && (clientToken.startsWith("pk_test_") || clientToken.startsWith("pk_live_"));
}
+6
View File
@@ -11,3 +11,9 @@ verify_jwt = false
[functions.submit-order]
verify_jwt = false
[functions.create-checkout]
verify_jwt = false
[functions.payments-webhook]
verify_jwt = false
+87
View File
@@ -0,0 +1,87 @@
import { encode } from "https://deno.land/std@0.168.0/encoding/hex.ts";
import Stripe from "https://esm.sh/stripe@22.0.2";
const getEnv = (key: string): string => {
const value = Deno.env.get(key);
if (!value) throw new Error(`${key} is not configured`);
return value;
};
export type StripeEnv = "sandbox" | "live";
const GATEWAY_STRIPE_BASE = "https://connector-gateway.lovable.dev/stripe";
export function getConnectionApiKey(env: StripeEnv): string {
return env === "sandbox"
? getEnv("STRIPE_SANDBOX_API_KEY")
: getEnv("STRIPE_LIVE_API_KEY");
}
export function createStripeClient(env: StripeEnv): Stripe {
const connectionApiKey = getConnectionApiKey(env);
const lovableApiKey = getEnv("LOVABLE_API_KEY");
return new Stripe(connectionApiKey, {
apiVersion: "2026-03-25.dahlia" as any,
httpClient: Stripe.createFetchHttpClient((input, init) => {
const stripeUrl = input instanceof Request ? input.url : input.toString();
const gatewayUrl = stripeUrl.replace("https://api.stripe.com", GATEWAY_STRIPE_BASE);
return fetch(gatewayUrl, {
...init,
headers: {
...Object.fromEntries(
new Headers(
init?.headers ?? (input instanceof Request ? input.headers : undefined),
).entries(),
),
"X-Connection-Api-Key": connectionApiKey,
"Lovable-API-Key": lovableApiKey,
},
});
}),
});
}
export async function verifyWebhook(
req: Request,
env: StripeEnv,
): Promise<{ type: string; data: { object: any } }> {
const signature = req.headers.get("stripe-signature");
const body = await req.text();
const secret =
env === "sandbox"
? getEnv("PAYMENTS_SANDBOX_WEBHOOK_SECRET")
: getEnv("PAYMENTS_LIVE_WEBHOOK_SECRET");
if (!signature || !body) throw new Error("Missing signature or body");
let timestamp: string | undefined;
const v1Signatures: string[] = [];
for (const part of signature.split(",")) {
const [key, value] = part.split("=", 2);
if (key === "t") timestamp = value;
if (key === "v1") v1Signatures.push(value);
}
if (!timestamp || v1Signatures.length === 0) throw new Error("Invalid signature format");
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 300) throw new Error("Webhook timestamp too old");
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signed = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(`${timestamp}.${body}`),
);
const expected = new TextDecoder().decode(encode(new Uint8Array(signed)));
if (!v1Signatures.includes(expected)) throw new Error("Invalid webhook signature");
return JSON.parse(body);
}
+176
View File
@@ -0,0 +1,176 @@
import { corsHeaders } from "npm:@supabase/supabase-js@2/cors";
import { z } from "npm:zod@3.23.8";
import { type StripeEnv, createStripeClient } from "../_shared/stripe.ts";
const PRICE_PER_KG_ORE = 32500; // 325 kr in öre
const WEEKS_PER_MONTH = 4;
const BodySchema = z.object({
method: z.enum(["card", "invoice"]),
employees: z.number().int().min(1).max(500),
kg_per_week: z.number().min(1).max(200),
company: z.string().trim().min(1).max(200),
email: z.string().trim().email().max(255),
phone: z.string().trim().max(30).optional().default(""),
address: z.string().trim().max(200).optional().default(""),
postal_code: z.string().trim().min(1).max(20),
city: z.string().trim().max(100).optional().default(""),
comments: z.string().trim().max(1000).optional().default(""),
return_url: z.string().url(),
environment: z.enum(["sandbox", "live"]),
});
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
if (req.method !== "POST") {
return new Response(JSON.stringify({ error: "Method not allowed" }), {
status: 405,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
try {
const raw = await req.json();
const parsed = BodySchema.safeParse(raw);
if (!parsed.success) {
return new Response(
JSON.stringify({ error: "Ogiltiga fält", details: parsed.error.flatten().fieldErrors }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const b = parsed.data;
// Stockholm postal enforcement
const cleaned = b.postal_code.replace(/\s/g, "");
if (!/^\d{5}$/.test(cleaned) || parseInt(cleaned, 10) < 10000 || parseInt(cleaned, 10) > 19999) {
return new Response(
JSON.stringify({ error: "Vi levererar endast inom Storstockholm (100 00 199 99)." }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
const env: StripeEnv = b.environment;
const stripe = createStripeClient(env);
// Monthly amount in öre = kg × 4 veckor × 325 kr
const monthlyAmountOre = Math.round(b.kg_per_week * WEEKS_PER_MONTH * PRICE_PER_KG_ORE);
// Resolve product via existing price lookup_key
const prices = await stripe.prices.list({ lookup_keys: ["kakabonnemang_dynamic"], limit: 1 });
if (!prices.data.length) {
return new Response(JSON.stringify({ error: "Produkt inte konfigurerad" }), {
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const productId =
typeof prices.data[0].product === "string"
? prices.data[0].product
: prices.data[0].product.id;
// Find or create customer
const existing = await stripe.customers.list({ email: b.email, limit: 1 });
const customer =
existing.data[0] ??
(await stripe.customers.create({
email: b.email,
name: b.company,
phone: b.phone || undefined,
address: b.address
? {
line1: b.address,
postal_code: cleaned,
city: b.city || undefined,
country: "SE",
}
: undefined,
metadata: {
company: b.company,
employees: String(b.employees),
kg_per_week: String(b.kg_per_week),
comments: b.comments,
},
}));
if (b.method === "card") {
// Embedded Checkout with dynamic recurring price_data
const session = await stripe.checkout.sessions.create({
mode: "subscription",
ui_mode: "embedded_page" as any,
return_url: b.return_url,
customer: customer.id,
line_items: [
{
quantity: 1,
price_data: {
currency: "sek",
product: productId,
recurring: { interval: "month" },
unit_amount: monthlyAmountOre,
} as any,
},
],
automatic_tax: { enabled: true },
customer_update: { address: "auto", name: "auto" },
subscription_data: {
description: `Kakabonnemang ${b.kg_per_week} kg/vecka`,
metadata: {
company: b.company,
kg_per_week: String(b.kg_per_week),
employees: String(b.employees),
},
},
metadata: {
company: b.company,
kg_per_week: String(b.kg_per_week),
},
} as any);
return new Response(JSON.stringify({ clientSecret: session.client_secret }), {
status: 200,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
// Invoice method: create subscription with send_invoice
const subscription = await stripe.subscriptions.create({
customer: customer.id,
collection_method: "send_invoice",
days_until_due: 30,
items: [
{
price_data: {
currency: "sek",
product: productId,
recurring: { interval: "month" },
unit_amount: monthlyAmountOre,
tax_behavior: "exclusive",
} as any,
quantity: 1,
},
],
automatic_tax: { enabled: true },
description: `Kakabonnemang ${b.kg_per_week} kg/vecka`,
metadata: {
company: b.company,
kg_per_week: String(b.kg_per_week),
employees: String(b.employees),
},
} as any);
return new Response(
JSON.stringify({
invoice: true,
subscription_id: subscription.id,
message: "Faktura skickas till din e-post inom kort.",
}),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (err: any) {
console.error("create-checkout error:", err);
return new Response(
JSON.stringify({ error: err?.message ?? "Något gick fel vid checkout" }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
});
@@ -0,0 +1,27 @@
import { verifyWebhook, type StripeEnv } from "../_shared/stripe.ts";
Deno.serve(async (req) => {
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
const rawEnv = new URL(req.url).searchParams.get("env");
if (rawEnv !== "sandbox" && rawEnv !== "live") {
return new Response(JSON.stringify({ received: true, ignored: "invalid env" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const env: StripeEnv = rawEnv;
try {
const event = await verifyWebhook(req, env);
console.log(`[payments-webhook:${env}] ${event.type}`);
// Minimal handler — Stripe dashboard is source of truth for subscriptions.
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (e) {
console.error("Webhook error:", e);
return new Response("Webhook error", { status: 400 });
}
});