mirror of
https://github.com/jcreek/SvelteKitSaasBoilerplate.git
synced 2026-07-13 02:53:50 +00:00
feat(*): Add basket functionality
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let imgAlt = '';
|
||||
export let imgSrc = '';
|
||||
export let itemCategoryDescription = '';
|
||||
export let itemId = 0;
|
||||
export let itemName = '';
|
||||
export let price = 0;
|
||||
export let quantity = 0;
|
||||
|
||||
function reduceQuantityByOne() {
|
||||
if (quantity > 1) {
|
||||
quantity--;
|
||||
dispatch('changeQuantity', { itemId, quantity });
|
||||
} else {
|
||||
dispatch('removeItem', { itemId });
|
||||
}
|
||||
}
|
||||
|
||||
function increaseQuantityByOne() {
|
||||
quantity++;
|
||||
dispatch('changeQuantity', { itemId, quantity });
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 min-[550px]:gap-6 border-t border-gray-200 py-6">
|
||||
<div
|
||||
class="flex items-center flex-col min-[550px]:flex-row gap-3 min-[550px]:gap-6 w-full max-xl:justify-center max-xl:max-w-xl max-xl:mx-auto"
|
||||
>
|
||||
<div class="img-box">
|
||||
<img src={imgSrc} alt={imgAlt} class="xl:w-[140px]" />
|
||||
</div>
|
||||
<div class="pro-data w-full max-w-sm">
|
||||
<h5 class="font-semibold text-xl leading-8 text-black max-[550px]:text-center">
|
||||
{itemName}
|
||||
</h5>
|
||||
<p
|
||||
class="font-normal text-lg leading-8 text-gray-500 my-2 min-[550px]:my-3 max-[550px]:text-center"
|
||||
>
|
||||
{itemCategoryDescription}
|
||||
</p>
|
||||
<h6 class="font-medium text-lg leading-8 text-indigo-600 max-[550px]:text-center">
|
||||
£ {price.toFixed(2)}
|
||||
</h6>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center flex-col min-[550px]:flex-row w-full max-xl:max-w-xl max-xl:mx-auto gap-2"
|
||||
>
|
||||
<div class="flex items-center w-full mx-auto justify-center">
|
||||
<button
|
||||
class="group rounded-l-full px-6 py-[18px] border border-gray-200 flex items-center justify-center shadow-sm shadow-transparent transition-all duration-500 hover:shadow-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
||||
on:click={reduceQuantityByOne}
|
||||
>
|
||||
<svg
|
||||
class="stroke-gray-900 transition-all duration-500 group-hover:stroke-black"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 22 22"
|
||||
fill="none"
|
||||
>
|
||||
<path d="M16.5 11H5.5" stroke="" stroke-width="1.6" stroke-linecap="round" />
|
||||
<path
|
||||
d="M16.5 11H5.5"
|
||||
stroke=""
|
||||
stroke-opacity="0.2"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M16.5 11H5.5"
|
||||
stroke=""
|
||||
stroke-opacity="0.2"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
class="border-y border-gray-200 outline-none text-gray-900 font-semibold text-lg w-full max-w-[118px] min-w-[80px] placeholder:text-gray-900 py-[15px] text-center bg-transparent"
|
||||
placeholder="1"
|
||||
bind:value={quantity}
|
||||
/>
|
||||
<button
|
||||
class="group rounded-r-full px-6 py-[18px] border border-gray-200 flex items-center justify-center shadow-sm shadow-transparent transition-all duration-500 hover:shadow-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
||||
on:click={increaseQuantityByOne}
|
||||
>
|
||||
<svg
|
||||
class="stroke-gray-900 transition-all duration-500 group-hover:stroke-black"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 22 22"
|
||||
fill="none"
|
||||
>
|
||||
<path d="M11 5.5V16.5M16.5 11H5.5" stroke="" stroke-width="1.6" stroke-linecap="round" />
|
||||
<path
|
||||
d="M11 5.5V16.5M16.5 11H5.5"
|
||||
stroke=""
|
||||
stroke-opacity="0.2"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M11 5.5V16.5M16.5 11H5.5"
|
||||
stroke=""
|
||||
stroke-opacity="0.2"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<h6
|
||||
class="text-indigo-600 font-manrope font-bold text-2xl leading-9 w-full max-w-[176px] text-center"
|
||||
>
|
||||
£{(price * quantity).toFixed(2)}
|
||||
</h6>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
const loadInitialState = () => {
|
||||
// Check if localStorage is available (i.e., we're on the client side)
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const storedData = localStorage.getItem('basket');
|
||||
return storedData ? JSON.parse(storedData) : getDefaultState();
|
||||
} else {
|
||||
return getDefaultState();
|
||||
}
|
||||
};
|
||||
|
||||
const getDefaultState = () => ({
|
||||
items: [],
|
||||
subtotal: 0
|
||||
});
|
||||
|
||||
export const basket = writable(loadInitialState());
|
||||
|
||||
// Subscribe to changes in the store and update localStorage accordingly
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
basket.subscribe((value) => {
|
||||
localStorage.setItem('basket', JSON.stringify(value));
|
||||
});
|
||||
}
|
||||
|
||||
export type Basket = {
|
||||
items: Item[];
|
||||
subtotal: number;
|
||||
};
|
||||
|
||||
export type Item = {
|
||||
id: number;
|
||||
categoryDescription: string;
|
||||
name: string;
|
||||
imgAlt: string;
|
||||
imgSrc: string;
|
||||
price: number;
|
||||
priceId: string;
|
||||
quantity: number;
|
||||
};
|
||||
@@ -1,28 +1,29 @@
|
||||
import { type RequestHandler, redirect } from '@sveltejs/kit';
|
||||
|
||||
// This is a public sample test API key.
|
||||
// Don’t submit any personally identifiable information in requests made with this key.
|
||||
// Sign in to see your own test API key embedded in code samples.
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
import { PUBLIC_STRIPE_SECRET_KEY } from '$env/static/public';
|
||||
import stripe from 'stripe';
|
||||
const stripeSecretKey =
|
||||
'sk_test_51PBMpZLiKlsU6INBHEsWwU2ekDfmotWTZghSl0XRLhtWp6mU7cBIfyBs6eBnG5kgC6MUhnvH5lUm2AbSWxgGfnKm00qTpaUnAP';
|
||||
const stripeClient = new stripe(stripeSecretKey);
|
||||
|
||||
const stripeClient = new stripe(PUBLIC_STRIPE_SECRET_KEY);
|
||||
|
||||
// Create a checkout session
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
// const { priceId } = await request.json(); // Assuming you'll send the price ID from the client
|
||||
const formData = new URLSearchParams(await request.text());
|
||||
const items: { priceId: string; quantity: number }[] = [];
|
||||
|
||||
// Iterate over form data to extract basket items and their details
|
||||
for (const [key, value] of formData) {
|
||||
items.push({ priceId: key, quantity: parseInt(value, 10) });
|
||||
}
|
||||
|
||||
const lineItems = items.map((item) => ({
|
||||
price: item.priceId,
|
||||
quantity: item.quantity
|
||||
}));
|
||||
|
||||
const session = await stripeClient.checkout.sessions.create({
|
||||
line_items: [
|
||||
{
|
||||
// Provide the exact Price ID (for example, pr_1234) of the product you want to sell
|
||||
price: 'price_1PBNHRLiKlsU6INBjXMcRxxS', //priceId,
|
||||
quantity: 1
|
||||
}
|
||||
],
|
||||
line_items: lineItems,
|
||||
mode: 'payment',
|
||||
success_url: `${request.headers.get('origin')}/checkout/success`,
|
||||
cancel_url: `${request.headers.get('origin')}/checkout/cancel`
|
||||
cancel_url: `${request.headers.get('origin')}/checkout/cancelled`
|
||||
});
|
||||
|
||||
return redirect(303, session.url as string);
|
||||
|
||||
@@ -1,14 +1,129 @@
|
||||
<script src="https://js.stripe.com/v3/"></script>
|
||||
<script src="https://js.stripe.com/v3/" lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { basket, type Basket, type Item } from '$lib/stores/basket.js';
|
||||
import BasketItem from '$lib/components/checkout/BasketItem.svelte';
|
||||
|
||||
<section>
|
||||
<div class="product">
|
||||
<img src="https://i.imgur.com/EHyR2nP.png" alt="The cover of Stubborn Attachments" />
|
||||
<div class="description">
|
||||
<h3>Stubborn Attachments</h3>
|
||||
<h5>$20.00</h5>
|
||||
let localBasket: Basket;
|
||||
const unsubscribe = basket.subscribe((value) => {
|
||||
localBasket = value;
|
||||
});
|
||||
onDestroy(unsubscribe);
|
||||
onMount(() => {
|
||||
calculateSubtotal();
|
||||
});
|
||||
|
||||
function calculateSubtotal() {
|
||||
let subtotal = 0;
|
||||
localBasket.items.forEach((item) => {
|
||||
subtotal += item.price * item.quantity;
|
||||
});
|
||||
localBasket.subtotal = subtotal;
|
||||
}
|
||||
|
||||
function removeItem(event: CustomEvent<{ itemId: number }>) {
|
||||
localBasket.items = localBasket.items.filter((item) => item.id !== event.detail.itemId);
|
||||
basket.set(localBasket);
|
||||
calculateSubtotal();
|
||||
}
|
||||
|
||||
function changeQuantity(event: CustomEvent<{ itemId: number; quantity: number }>) {
|
||||
localBasket.items = localBasket.items.map((item) => {
|
||||
if (item.id === event.detail.itemId) {
|
||||
item.quantity = event.detail.quantity;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
basket.set(localBasket);
|
||||
calculateSubtotal();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="py-24 relative">
|
||||
<div class="w-full max-w-7xl px-4 md:px-5 lg-6 mx-auto">
|
||||
<h2 class="title font-manrope font-bold text-4xl leading-10 mb-8 text-center text-black">
|
||||
Basket
|
||||
</h2>
|
||||
<div class="hidden lg:grid grid-cols-2 py-6">
|
||||
<div class="font-normal text-xl leading-8 text-gray-500">Product</div>
|
||||
<p class="font-normal text-xl leading-8 text-gray-500 flex items-center justify-between">
|
||||
<span class="w-full max-w-[260px] text-center">Quantity</span>
|
||||
<span class="w-full max-w-[200px] text-center">Total</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#each localBasket.items as item (item.id)}
|
||||
<BasketItem
|
||||
itemCategoryDescription={item.categoryDescription}
|
||||
itemId={item.id}
|
||||
itemName={item.name}
|
||||
imgAlt={item.imgAlt}
|
||||
imgSrc={item.imgSrc}
|
||||
price={item.price}
|
||||
quantity={item.quantity}
|
||||
on:changeQuantity={changeQuantity}
|
||||
on:removeItem={removeItem}
|
||||
/>
|
||||
{/each}
|
||||
<div class="bg-gray-50 rounded-xl p-6 w-full mb-8 max-lg:max-w-xl max-lg:mx-auto">
|
||||
<div class="flex items-center justify-between w-full pb-6 border-b border-gray-200">
|
||||
<p class="font-normal text-base leading-8 text-gray-400">
|
||||
Shipping, taxes and discounts calculated at checkout
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full py-6">
|
||||
<p class="font-manrope font-medium text-2xl leading-9 text-gray-900">Subtotal</p>
|
||||
<h6 class="font-manrope font-medium text-2xl leading-9 text-indigo-500">
|
||||
£{localBasket.subtotal.toFixed(2)}
|
||||
</h6>
|
||||
</div>
|
||||
</div>
|
||||
<form action="/api/checkout" method="POST">
|
||||
{#each localBasket.items as item (item.id)}
|
||||
<input type="hidden" name={item.priceId} value={item.quantity} />
|
||||
{/each}
|
||||
|
||||
<div class="flex items-center flex-col sm:flex-row justify-center gap-3 mt-8">
|
||||
<a href="/" class="btn">
|
||||
Continue shopping
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="22"
|
||||
height="22"
|
||||
viewBox="0 0 22 22"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M8.25324 5.49609L13.7535 10.9963L8.25 16.4998"
|
||||
stroke="#4F46E5"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled={localBasket.items && localBasket.items.length === 0}
|
||||
>Continue to Payment
|
||||
<svg
|
||||
class="ml-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="23"
|
||||
height="22"
|
||||
viewBox="0 0 23 22"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M8.75324 5.49609L14.2535 10.9963L8.75 16.4998"
|
||||
stroke="white"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<form action="/api/checkout" method="POST">
|
||||
<button type="submit" id="checkout-button">Checkout</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user