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 Admin from "./pages/Admin";
|
||||||
import FAQ from "./pages/FAQ";
|
import FAQ from "./pages/FAQ";
|
||||||
import OrderConfirmation from "./pages/OrderConfirmation";
|
import OrderConfirmation from "./pages/OrderConfirmation";
|
||||||
|
import ChangeSubscription from "./pages/ChangeSubscription";
|
||||||
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
|
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
@@ -31,6 +32,7 @@ function AppContent() {
|
|||||||
<Route path="/account/confirmation" element={<AccountConfirmation />} />
|
<Route path="/account/confirmation" element={<AccountConfirmation />} />
|
||||||
<Route path="/admin" element={<Admin />} />
|
<Route path="/admin" element={<Admin />} />
|
||||||
<Route path="/order-confirmation" element={<OrderConfirmation />} />
|
<Route path="/order-confirmation" element={<OrderConfirmation />} />
|
||||||
|
<Route path="/account/change-subscription" element={<ChangeSubscription />} />
|
||||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -413,6 +413,16 @@ const Account = () => {
|
|||||||
</p>
|
</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
|
<Button
|
||||||
onClick={handleManageSubscription}
|
onClick={handleManageSubscription}
|
||||||
disabled={openingPortal}
|
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]
|
[functions.create-portal-session]
|
||||||
verify_jwt = false
|
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