Byggt konto-sidan med abon.
X-Lovable-Edit-ID: edt-b85ec815-1193-40d9-9ff6-1fc7e088a9bb Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import Contact from "./pages/Contact";
|
||||
import Admin from "./pages/Admin";
|
||||
import FAQ from "./pages/FAQ";
|
||||
import OrderConfirmation from "./pages/OrderConfirmation";
|
||||
import ChangeSubscription from "./pages/ChangeSubscription";
|
||||
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
@@ -31,6 +32,7 @@ function AppContent() {
|
||||
<Route path="/account/confirmation" element={<AccountConfirmation />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/order-confirmation" element={<OrderConfirmation />} />
|
||||
<Route path="/account/change-subscription" element={<ChangeSubscription />} />
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
|
||||
@@ -413,6 +413,16 @@ const Account = () => {
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={() => navigate("/account/change-subscription")}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={subscription.cancel_at_period_end || subscription.status === "canceled"}
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Ändra kg/vecka
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleManageSubscription}
|
||||
disabled={openingPortal}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { supabase } from "@/integrations/supabase/client";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { getStripeEnvironment, hasPaymentsConfigured } from "@/lib/stripe";
|
||||
import Header from "@/components/Header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
|
||||
const PRICE_PER_KG_EXKL = 325;
|
||||
const VAT_RATE = 0.25;
|
||||
const PRICE_PER_KG_INK = PRICE_PER_KG_EXKL * (1 + VAT_RATE);
|
||||
const WEEKS_PER_MONTH = 4;
|
||||
|
||||
const ChangeSubscription = () => {
|
||||
const navigate = useNavigate();
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [currentKg, setCurrentKg] = useState<number | null>(null);
|
||||
const [newKg, setNewKg] = useState<number>(10);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) navigate("/");
|
||||
}, [authLoading, user, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!user) return;
|
||||
const env = hasPaymentsConfigured() ? getStripeEnvironment() : "sandbox";
|
||||
const { data } = await supabase
|
||||
.from("subscriptions")
|
||||
.select("kg_per_week, status")
|
||||
.eq("user_id", user.id)
|
||||
.eq("environment", env)
|
||||
.in("status", ["active", "trialing", "past_due"])
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (!data) {
|
||||
toast({
|
||||
title: "Inget aktivt abonnemang",
|
||||
description: "Du behöver ett aktivt abonnemang för att ändra mängd.",
|
||||
variant: "destructive",
|
||||
});
|
||||
navigate("/account");
|
||||
return;
|
||||
}
|
||||
const kg = Number(data.kg_per_week ?? 10);
|
||||
setCurrentKg(kg);
|
||||
setNewKg(kg);
|
||||
setLoading(false);
|
||||
})();
|
||||
}, [user, navigate, toast]);
|
||||
|
||||
const newMonthlyInk = useMemo(
|
||||
() => newKg * WEEKS_PER_MONTH * PRICE_PER_KG_INK,
|
||||
[newKg],
|
||||
);
|
||||
const currentMonthlyInk = useMemo(
|
||||
() => (currentKg ?? 0) * WEEKS_PER_MONTH * PRICE_PER_KG_INK,
|
||||
[currentKg],
|
||||
);
|
||||
const diff = newMonthlyInk - currentMonthlyInk;
|
||||
const unchanged = currentKg !== null && newKg === currentKg;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasPaymentsConfigured()) {
|
||||
toast({ title: "Betalningar inte konfigurerade", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke("update-subscription-quantity", {
|
||||
body: { new_kg_per_week: newKg, environment: getStripeEnvironment() },
|
||||
});
|
||||
if (error) throw error;
|
||||
if ((data as any)?.error) throw new Error((data as any).error);
|
||||
toast({
|
||||
title: "Abonnemang uppdaterat",
|
||||
description: `Ny mängd: ${newKg} kg/vecka. Prisdifferens justeras på nästa faktura.`,
|
||||
});
|
||||
navigate("/account");
|
||||
} catch (e: any) {
|
||||
toast({
|
||||
title: "Kunde inte uppdatera",
|
||||
description: e?.message ?? String(e),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (authLoading || loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="pt-24 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<Header />
|
||||
<div className="pt-24 px-4 pb-16">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/account")}
|
||||
className="text-sm text-muted-foreground hover:underline mb-6 flex items-center gap-1"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Tillbaka till mitt konto
|
||||
</button>
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-24 h-px bg-primary/40 mx-auto mb-6" />
|
||||
<h1 className="font-display text-3xl md:text-5xl font-semibold mb-2">
|
||||
Ändra abonnemang
|
||||
</h1>
|
||||
<p className="text-muted-foreground font-display italic">
|
||||
Justera antal kg kakor per vecka
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-card border border-border rounded-sm p-6 md:p-10 shadow-[0_10px_40px_-15px_rgba(0,0,0,0.15)] space-y-8">
|
||||
<div className="text-center">
|
||||
<div className="text-muted-foreground text-xs uppercase tracking-widest mb-2">
|
||||
Ny mängd
|
||||
</div>
|
||||
<div
|
||||
key={newKg}
|
||||
className="font-display text-6xl md:text-7xl font-semibold text-primary tabular-nums animate-fade-in"
|
||||
>
|
||||
{newKg} kg
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm mt-1">per vecka</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
value={newKg}
|
||||
onChange={(e) => setNewKg(parseInt(e.target.value, 10))}
|
||||
className="calculator-slider w-full"
|
||||
aria-label="Kg per vecka"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground mt-3">
|
||||
<span>1 kg</span>
|
||||
<span>100 kg</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-6 space-y-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Nuvarande månadskostnad</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{Math.round(currentMonthlyInk).toLocaleString("sv-SE")} kr
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Ny månadskostnad</span>
|
||||
<span className="font-medium tabular-nums text-primary">
|
||||
{Math.round(newMonthlyInk).toLocaleString("sv-SE")} kr
|
||||
</span>
|
||||
</div>
|
||||
{!unchanged && (
|
||||
<div className="flex justify-between text-sm border-t border-border pt-3">
|
||||
<span className="text-muted-foreground">Skillnad</span>
|
||||
<span
|
||||
className={`font-semibold tabular-nums ${
|
||||
diff > 0 ? "text-foreground" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
{diff > 0 ? "+" : ""}
|
||||
{Math.round(diff).toLocaleString("sv-SE")} kr/mån
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground text-center pt-2">
|
||||
ink moms · prisdifferens proportioneras på nästa faktura
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || unchanged}
|
||||
className="w-full py-6 text-base"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> Uppdaterar...
|
||||
</>
|
||||
) : unchanged ? (
|
||||
"Ingen ändring gjord"
|
||||
) : (
|
||||
`Bekräfta ${newKg} kg/vecka`
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangeSubscription;
|
||||
@@ -20,3 +20,6 @@ verify_jwt = false
|
||||
|
||||
[functions.create-portal-session]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.update-subscription-quantity]
|
||||
verify_jwt = false
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
|
||||
import { z } from "npm:zod@3.23.8";
|
||||
import { type StripeEnv, createStripeClient } from "../_shared/stripe.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers":
|
||||
"authorization, x-client-info, x-supabase-client-platform, x-supabase-api-version, apikey, content-type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
};
|
||||
|
||||
const PRICE_PER_KG_ORE = 32500; // 325 kr exkl. moms
|
||||
const WEEKS_PER_MONTH = 4;
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!,
|
||||
);
|
||||
|
||||
const BodySchema = z.object({
|
||||
new_kg_per_week: z.number().min(1).max(200),
|
||||
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 token = req.headers.get("Authorization")?.replace("Bearer ", "") ?? "";
|
||||
const { data: userData, error: authErr } = await supabase.auth.getUser(token);
|
||||
if (authErr || !userData?.user) {
|
||||
return new Response(JSON.stringify({ error: "Ej inloggad" }), {
|
||||
status: 401,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = BodySchema.safeParse(await req.json());
|
||||
if (!parsed.success) {
|
||||
return new Response(JSON.stringify({ error: "Ogiltiga fält" }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const env: StripeEnv = parsed.data.environment;
|
||||
const newKg = parsed.data.new_kg_per_week;
|
||||
|
||||
const { data: sub } = await supabase
|
||||
.from("subscriptions")
|
||||
.select("id, stripe_subscription_id, kg_per_week")
|
||||
.eq("user_id", userData.user.id)
|
||||
.eq("environment", env)
|
||||
.in("status", ["active", "trialing", "past_due"])
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (!sub?.stripe_subscription_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Inget aktivt abonnemang hittades." }),
|
||||
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const stripe = createStripeClient(env);
|
||||
const stripeSub = await stripe.subscriptions.retrieve(sub.stripe_subscription_id);
|
||||
const item = stripeSub.items?.data?.[0];
|
||||
if (!item) {
|
||||
return new Response(JSON.stringify({ error: "Kunde inte hitta prisrad" }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const productId =
|
||||
typeof item.price.product === "string" ? item.price.product : item.price.product.id;
|
||||
const newUnitAmount = Math.round(newKg * WEEKS_PER_MONTH * PRICE_PER_KG_ORE);
|
||||
|
||||
await stripe.subscriptions.update(sub.stripe_subscription_id, {
|
||||
items: [
|
||||
{
|
||||
id: item.id,
|
||||
price_data: {
|
||||
currency: "sek",
|
||||
product: productId,
|
||||
recurring: { interval: "month" },
|
||||
unit_amount: newUnitAmount,
|
||||
} as any,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
proration_behavior: "create_prorations",
|
||||
metadata: {
|
||||
...(stripeSub.metadata ?? {}),
|
||||
kg_per_week: String(newKg),
|
||||
},
|
||||
description: `Kakabonnemang – ${newKg} kg/vecka`,
|
||||
} as any);
|
||||
|
||||
// Uppdatera vår tabell direkt (webhook uppdaterar också, detta ger snabb UI-återkoppling)
|
||||
await supabase
|
||||
.from("subscriptions")
|
||||
.update({
|
||||
kg_per_week: newKg,
|
||||
monthly_amount: newUnitAmount / 100,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("id", sub.id);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
new_kg_per_week: newKg,
|
||||
new_monthly_amount_exkl_moms: newUnitAmount / 100,
|
||||
}),
|
||||
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error("update-subscription-quantity error:", e);
|
||||
return new Response(JSON.stringify({ error: e?.message ?? "Kunde inte uppdatera abonnemang" }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user