Bytte flödet till Stripe

X-Lovable-Edit-ID: edt-92829336-6ccd-44f7-b020-97ef2217be1c
Co-authored-by: wolfoftyreso-debug <250630591+wolfoftyreso-debug@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-07-11 10:40:32 +00:00
6 changed files with 79 additions and 478 deletions
+1 -2
View File
@@ -12,12 +12,11 @@ import AccountConfirmation from "./pages/AccountConfirmation";
import Contact from "./pages/Contact";
import Admin from "./pages/Admin";
import FAQ from "./pages/FAQ";
import { useCartSync } from "./hooks/useCartSync";
const queryClient = new QueryClient();
function AppContent() {
useCartSync();
return (
<BrowserRouter>
-149
View File
@@ -1,149 +0,0 @@
import { useState, useEffect } from "react";
import { ShoppingCart, Trash2, ExternalLink, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { useCartStore } from "@/stores/cartStore";
export const CartDrawer = () => {
const [isOpen, setIsOpen] = useState(false);
const { items, isLoading, isSyncing, removeItem, clearCart, getCheckoutUrl, syncCart } = useCartStore();
const totalItems = items.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = items.reduce((sum, item) => sum + (parseFloat(item.price.amount) * item.quantity), 0);
// Sync cart with Shopify when drawer opens
useEffect(() => {
if (isOpen) syncCart();
}, [isOpen, syncCart]);
const handleCheckout = () => {
const checkoutUrl = getCheckoutUrl();
if (checkoutUrl) {
window.open(checkoutUrl, '_blank');
setIsOpen(false);
}
};
const handleRemoveItem = async (variantId: string) => {
await removeItem(variantId);
};
if (totalItems === 0) {
return null;
}
return (
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="relative">
<ShoppingCart className="h-5 w-5" />
{totalItems > 0 && (
<Badge className="absolute -top-2 -right-2 h-5 w-5 rounded-full p-0 flex items-center justify-center text-xs bg-primary text-primary-foreground">
{totalItems}
</Badge>
)}
</Button>
</SheetTrigger>
<SheetContent className="w-full sm:max-w-md flex flex-col h-full">
<SheetHeader className="flex-shrink-0">
<SheetTitle>Varukorg</SheetTitle>
<SheetDescription>
{totalItems === 0 ? "Din varukorg är tom" : `${totalItems} produkt${totalItems !== 1 ? 'er' : ''} i varukorgen`}
</SheetDescription>
</SheetHeader>
<div className="flex flex-col flex-1 pt-6 min-h-0">
{items.length === 0 ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<ShoppingCart className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">Din varukorg är tom</p>
</div>
</div>
) : (
<>
<div className="flex-1 overflow-y-auto pr-2 min-h-0">
<div className="space-y-4">
{items.map((item) => (
<div key={item.variantId} className="flex gap-4 p-3 bg-muted/30 rounded-lg">
<div className="flex-1 min-w-0">
<h4 className="font-medium text-sm truncate">
{item.product.node.title}
</h4>
<p className="text-sm text-muted-foreground">
Antal: {item.quantity}
</p>
<p className="font-semibold text-primary">
{parseFloat(item.price.amount).toLocaleString('sv-SE')} {item.price.currencyCode}
</p>
</div>
<div className="flex flex-col items-end justify-center">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleRemoveItem(item.variantId)}
disabled={isLoading}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
<div className="flex-shrink-0 space-y-4 pt-4 border-t bg-background">
<div className="flex justify-between items-center">
<span className="text-lg font-semibold">Totalt</span>
<span className="text-xl font-bold text-primary">
{totalPrice.toLocaleString('sv-SE')} {items[0]?.price.currencyCode || 'SEK'}
</span>
</div>
<Button
onClick={handleCheckout}
className="w-full"
size="lg"
disabled={items.length === 0 || isLoading || isSyncing}
>
{isLoading || isSyncing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<>
<ExternalLink className="w-4 h-4 mr-2" />
till betalning
</>
)}
</Button>
<Button
variant="outline"
onClick={() => {
clearCart();
setIsOpen(false);
}}
className="w-full text-muted-foreground"
disabled={isLoading}
>
<Trash2 className="w-4 h-4 mr-2" />
Töm varukorg
</Button>
</div>
</>
)}
</div>
</SheetContent>
</Sheet>
);
};
export default CartDrawer;
+1 -3
View File
@@ -1,5 +1,4 @@
import AccountMenu from "./AccountMenu";
import CartDrawer from "./CartDrawer";
const Header = () => {
return (
@@ -13,7 +12,6 @@ const Header = () => {
/>
</a>
<div className="flex items-center gap-3">
<CartDrawer />
<AccountMenu />
</div>
</div>
@@ -21,4 +19,4 @@ const Header = () => {
);
};
export default Header;
export default Header;
+77 -33
View File
@@ -1,7 +1,11 @@
import { useState, useMemo } from "react";
import { Loader2 } from "lucide-react";
import { Loader2, CreditCard, FileText } from "lucide-react";
import { useShopifyProducts } from "@/hooks/useShopifyProducts";
import { useCartStore } from "@/stores/cartStore";
import {
storefrontApiRequest,
CART_CREATE_MUTATION,
formatCheckoutUrl,
} from "@/lib/shopify";
import { toast } from "sonner";
const PRICE_PER_KG = 325;
@@ -19,12 +23,15 @@ const pickPlan = (kg: number) => {
return match || availablePlans[availablePlans.length - 1];
};
type PaymentMethod = "card" | "invoice";
const PlansSection = () => {
const [employees, setEmployees] = useState(10);
const [checkoutLoading, setCheckoutLoading] = useState(false);
const [loadingMethod, setLoadingMethod] = useState<PaymentMethod | null>(null);
const { products, isLoading: productsLoading } = useShopifyProducts("product_type:Abonnemang");
const { addItem, clearCart, isLoading: cartLoading } = useCartStore();
const { products, isLoading: productsLoading } = useShopifyProducts(
"product_type:Abonnemang"
);
const recommendedKg = useMemo(
() => Math.max(1, Math.round(employees * KG_PER_EMPLOYEE)),
@@ -32,7 +39,7 @@ const PlansSection = () => {
);
const weeklyPrice = recommendedKg * PRICE_PER_KG;
const handleStart = async () => {
const handleStart = async (method: PaymentMethod) => {
const plan = pickPlan(recommendedKg);
const shopifyProduct = products.find((p) => p.node.handle === plan.handle);
@@ -46,29 +53,46 @@ const PlansSection = () => {
return;
}
setCheckoutLoading(true);
setLoadingMethod(method);
try {
clearCart();
await addItem({
product: shopifyProduct,
variantId: variant.id,
variantTitle: variant.title,
price: variant.price,
quantity: 1,
selectedOptions: variant.selectedOptions || [],
});
toast.success(`Abonnemang tillagt (${plan.name} ${plan.kg} kg/vecka)`, {
description: "Klicka på varukorgen för att gå till betalning",
const data = await storefrontApiRequest(CART_CREATE_MUTATION, {
input: {
lines: [{ quantity: 1, merchandiseId: variant.id }],
attributes: [
{ key: "Antal anställda", value: String(employees) },
{ key: "Rekommenderad mängd", value: `${recommendedKg} kg/vecka` },
{ key: "Veckopris (exkl. moms)", value: `${weeklyPrice} kr` },
{
key: "Önskad betalningsmetod",
value: method === "card" ? "Automatiskt kort varje månad" : "Månadsfaktura",
},
],
},
});
const errors = data?.data?.cartCreate?.userErrors || [];
if (errors.length > 0) {
console.error("Cart creation failed:", errors);
toast.error("Kunde inte skapa checkout");
return;
}
const checkoutUrl = data?.data?.cartCreate?.cart?.checkoutUrl;
if (!checkoutUrl) {
toast.error("Ingen checkout-länk mottagen");
return;
}
window.open(formatCheckoutUrl(checkoutUrl), "_blank");
} catch (e) {
console.error(e);
toast.error("Något gick fel");
} finally {
setCheckoutLoading(false);
setLoadingMethod(null);
}
};
const isLoading = productsLoading || cartLoading || checkoutLoading;
const isBusy = productsLoading || loadingMethod !== null;
return (
<section id="abonnemang" className="py-16 md:py-28 px-6 bg-background">
@@ -139,20 +163,40 @@ const PlansSection = () => {
<div className="text-muted-foreground text-xs mt-1">exklusive moms</div>
</div>
<button
onClick={handleStart}
disabled={isLoading}
className="btn-classic w-full py-4 text-base md:text-lg rounded-sm disabled:opacity-50 mt-4"
>
{isLoading ? (
<span className="flex items-center justify-center gap-2">
{/* Two CTAs — pick payment method, then straight to Shopify checkout */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2">
<button
onClick={() => handleStart("card")}
disabled={isBusy}
className="btn-classic py-4 px-4 text-base rounded-sm disabled:opacity-50 flex items-center justify-center gap-2"
>
{loadingMethod === "card" ? (
<Loader2 className="w-5 h-5 animate-spin" />
Laddar...
</span>
) : (
"Starta abonnemang"
)}
</button>
) : (
<>
<CreditCard className="w-5 h-5" />
<span>Automatiskt varje månad</span>
</>
)}
</button>
<button
onClick={() => handleStart("invoice")}
disabled={isBusy}
className="py-4 px-4 text-base rounded-sm border-2 border-primary text-primary hover:bg-primary hover:text-primary-foreground transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
>
{loadingMethod === "invoice" ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<FileText className="w-5 h-5" />
<span>Månadsfaktura</span>
</>
)}
</button>
</div>
<p className="text-muted-foreground text-xs">
Du slutför beställningen säkert hos Shopify.
</p>
</div>
</div>
</div>
-15
View File
@@ -1,15 +0,0 @@
import { useEffect } from 'react';
import { useCartStore } from '@/stores/cartStore';
export function useCartSync() {
const syncCart = useCartStore(state => state.syncCart);
useEffect(() => {
syncCart();
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') syncCart();
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, [syncCart]);
}
-276
View File
@@ -1,276 +0,0 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import {
storefrontApiRequest,
ShopifyProduct,
CART_QUERY,
CART_CREATE_MUTATION,
CART_LINES_ADD_MUTATION,
CART_LINES_UPDATE_MUTATION,
CART_LINES_REMOVE_MUTATION,
formatCheckoutUrl,
isCartNotFoundError
} from '@/lib/shopify';
export interface CartItem {
lineId: string | null;
product: ShopifyProduct;
variantId: string;
variantTitle: string;
price: { amount: string; currencyCode: string };
quantity: number;
selectedOptions: Array<{ name: string; value: string }>;
}
interface CartStore {
items: CartItem[];
cartId: string | null;
checkoutUrl: string | null;
isLoading: boolean;
isSyncing: boolean;
addItem: (item: Omit<CartItem, 'lineId'>) => Promise<void>;
updateQuantity: (variantId: string, quantity: number) => Promise<void>;
removeItem: (variantId: string) => Promise<void>;
clearCart: () => void;
syncCart: () => Promise<void>;
getCheckoutUrl: () => string | null;
}
async function createShopifyCart(item: CartItem): Promise<{ cartId: string; checkoutUrl: string; lineId: string } | null> {
const data = await storefrontApiRequest(CART_CREATE_MUTATION, {
input: { lines: [{ quantity: item.quantity, merchandiseId: item.variantId }] },
});
if (data?.data?.cartCreate?.userErrors?.length > 0) {
console.error('Cart creation failed:', data.data.cartCreate.userErrors);
return null;
}
const cart = data?.data?.cartCreate?.cart;
if (!cart?.checkoutUrl) return null;
const lineId = cart.lines.edges[0]?.node?.id;
if (!lineId) return null;
return { cartId: cart.id, checkoutUrl: formatCheckoutUrl(cart.checkoutUrl), lineId };
}
async function addLineToShopifyCart(cartId: string, item: CartItem): Promise<{ success: boolean; lineId?: string; cartNotFound?: boolean }> {
const data = await storefrontApiRequest(CART_LINES_ADD_MUTATION, {
cartId,
lines: [{ quantity: item.quantity, merchandiseId: item.variantId }],
});
const userErrors = data?.data?.cartLinesAdd?.userErrors || [];
if (isCartNotFoundError(userErrors)) return { success: false, cartNotFound: true };
if (userErrors.length > 0) {
console.error('Add line failed:', userErrors);
return { success: false };
}
const lines = data?.data?.cartLinesAdd?.cart?.lines?.edges || [];
const newLine = lines.find((l: { node: { id: string; merchandise: { id: string } } }) => l.node.merchandise.id === item.variantId);
return { success: true, lineId: newLine?.node?.id };
}
async function updateShopifyCartLine(cartId: string, lineId: string, quantity: number): Promise<{ success: boolean; cartNotFound?: boolean }> {
const data = await storefrontApiRequest(CART_LINES_UPDATE_MUTATION, {
cartId,
lines: [{ id: lineId, quantity }],
});
const userErrors = data?.data?.cartLinesUpdate?.userErrors || [];
if (isCartNotFoundError(userErrors)) return { success: false, cartNotFound: true };
if (userErrors.length > 0) {
console.error('Update line failed:', userErrors);
return { success: false };
}
return { success: true };
}
async function removeLineFromShopifyCart(cartId: string, lineId: string): Promise<{ success: boolean; cartNotFound?: boolean }> {
const data = await storefrontApiRequest(CART_LINES_REMOVE_MUTATION, {
cartId,
lineIds: [lineId],
});
const userErrors = data?.data?.cartLinesRemove?.userErrors || [];
if (isCartNotFoundError(userErrors)) return { success: false, cartNotFound: true };
if (userErrors.length > 0) {
console.error('Remove line failed:', userErrors);
return { success: false };
}
return { success: true };
}
export const useCartStore = create<CartStore>()(
persist(
(set, get) => ({
items: [],
cartId: null,
checkoutUrl: null,
isLoading: false,
isSyncing: false,
addItem: async (item) => {
const { items, cartId, clearCart } = get();
const existingItem = items.find(i => i.variantId === item.variantId);
set({ isLoading: true });
try {
if (!cartId) {
const result = await createShopifyCart({ ...item, lineId: null });
if (result) {
set({
cartId: result.cartId,
checkoutUrl: result.checkoutUrl,
items: [{ ...item, lineId: result.lineId }]
});
}
} else if (existingItem) {
const newQuantity = existingItem.quantity + item.quantity;
if (!existingItem.lineId) {
console.error('Cannot update quantity for item without lineId:', existingItem);
return;
}
const result = await updateShopifyCartLine(cartId, existingItem.lineId, newQuantity);
if (result.success) {
const currentItems = get().items;
set({ items: currentItems.map(i => i.variantId === item.variantId ? { ...i, quantity: newQuantity } : i) });
} else if (result.cartNotFound) {
clearCart();
}
} else {
const result = await addLineToShopifyCart(cartId, { ...item, lineId: null });
if (result.success) {
const currentItems = get().items;
set({ items: [...currentItems, { ...item, lineId: result.lineId ?? null }] });
} else if (result.cartNotFound) {
clearCart();
}
}
} catch (error) {
console.error('Failed to add item:', error);
} finally {
set({ isLoading: false });
}
},
updateQuantity: async (variantId, quantity) => {
if (quantity <= 0) {
await get().removeItem(variantId);
return;
}
const { items, cartId, clearCart } = get();
const item = items.find(i => i.variantId === variantId);
if (!item?.lineId || !cartId) return;
set({ isLoading: true });
try {
const result = await updateShopifyCartLine(cartId, item.lineId, quantity);
if (result.success) {
const currentItems = get().items;
set({ items: currentItems.map(i => i.variantId === variantId ? { ...i, quantity } : i) });
} else if (result.cartNotFound) {
clearCart();
}
} catch (error) {
console.error('Failed to update quantity:', error);
} finally {
set({ isLoading: false });
}
},
removeItem: async (variantId) => {
const { items, cartId, clearCart } = get();
const item = items.find(i => i.variantId === variantId);
if (!item?.lineId || !cartId) return;
set({ isLoading: true });
try {
const result = await removeLineFromShopifyCart(cartId, item.lineId);
if (result.success) {
const currentItems = get().items;
const newItems = currentItems.filter(i => i.variantId !== variantId);
newItems.length === 0 ? clearCart() : set({ items: newItems });
} else if (result.cartNotFound) {
clearCart();
}
} catch (error) {
console.error('Failed to remove item:', error);
} finally {
set({ isLoading: false });
}
},
clearCart: () => set({ items: [], cartId: null, checkoutUrl: null }),
getCheckoutUrl: () => get().checkoutUrl,
syncCart: async () => {
const { cartId, isSyncing, clearCart, items } = get();
if (!cartId || isSyncing) return;
set({ isSyncing: true });
try {
const data = await storefrontApiRequest(CART_QUERY, { id: cartId });
if (!data) {
set({ isSyncing: false });
return;
}
const cart = data?.data?.cart;
// Cart doesn't exist or is empty - clear local state
if (!cart || cart.totalQuantity === 0) {
clearCart();
return;
}
// Sync local items with Shopify cart
const shopifyLines = cart.lines?.edges || [];
// Build a map of Shopify line items by variant ID
const shopifyItemsMap = new Map<string, { lineId: string; quantity: number }>();
for (const edge of shopifyLines) {
const variantId = edge.node.merchandise.id;
shopifyItemsMap.set(variantId, {
lineId: edge.node.id,
quantity: edge.node.quantity
});
}
// Update local items to match Shopify state
const syncedItems = items
.filter(item => shopifyItemsMap.has(item.variantId))
.map(item => {
const shopifyItem = shopifyItemsMap.get(item.variantId)!;
return {
...item,
lineId: shopifyItem.lineId,
quantity: shopifyItem.quantity
};
});
// Update checkout URL if available
const checkoutUrl = cart.checkoutUrl ? formatCheckoutUrl(cart.checkoutUrl) : get().checkoutUrl;
if (syncedItems.length === 0) {
clearCart();
} else {
set({ items: syncedItems, checkoutUrl });
}
} catch (error) {
console.error('Failed to sync cart with Shopify:', error);
} finally {
set({ isSyncing: false });
}
}
}),
{
name: 'shopify-cart',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ items: state.items, cartId: state.cartId, checkoutUrl: state.checkoutUrl }),
}
)
);