Skip to content

Commit

Permalink
Merge pull request #131 from zackha/dev
Browse files Browse the repository at this point in the history
feat: cart and checkout functions added
  • Loading branch information
zackha authored Aug 27, 2024
2 parents 58f7c5f + 580ec59 commit 60147b7
Show file tree
Hide file tree
Showing 39 changed files with 346 additions and 306 deletions.
File renamed without changes.
3 changes: 3 additions & 0 deletions app.vue → app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ useHead({
<NuxtPage />
</div>
<AppFooter />
<Notivue v-slot="item">
<Notification :item="item" :theme="materialTheme" />
</Notivue>
</template>

<style lang="postcss">
Expand Down
File renamed without changes.
19 changes: 11 additions & 8 deletions components/AppHeader.vue → app/components/AppHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ const searchResults = ref([]);
const isLoading = ref(false);
const suggestionMenu = ref(false);
const onClickOutsideRef = ref(null);
const cart = ref(false);
const cartModal = ref(false);
const { cart } = useCart();
const search = () => {
router.push({ path: '/', query: { ...route.query, q: searchQuery.value || undefined } });
Expand Down Expand Up @@ -44,7 +45,7 @@ const clearSearch = () => {
onClickOutside(onClickOutsideRef, event => {
suggestionMenu.value = false;
cart.value = false;
cartModal.value = false;
});
</script>

Expand Down Expand Up @@ -115,12 +116,14 @@ onClickOutside(onClickOutsideRef, event => {
</div>
</div>
<button
@mouseup="cart = !cart"
@mouseup="cartModal = !cartModal"
class="hover:bg-black/5 hover:dark:bg-white/15 max-lg:dark:bg-white/15 max-lg:bg-black/5 max-lg:hover:bg-black/10 max-lg:hover:dark:bg-white/20 min-w-12 min-h-12 flex items-center justify-center rounded-full cursor-pointer relative">
<UIcon class="text-[#5f5f5f] dark:text-[#b7b7b7]" name="i-iconamoon-shopping-bag-fill" size="26" />
<span class="absolute top-1 right-1 flex h-[18px] w-[18px]">
<span v-if="cart.length" class="absolute top-1 right-1 flex h-[18px] w-[18px]">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-alizarin-crimson-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-[18px] w-[18px] bg-alizarin-crimson-700 text-[10px] items-center justify-center shadow font-semibold text-white">5</span>
<span class="relative inline-flex rounded-full h-[18px] w-[18px] bg-alizarin-crimson-700 text-[10px] items-center justify-center shadow font-semibold text-white">
{{ cart.length }}
</span>
</span>
</button>
</div>
Expand Down Expand Up @@ -199,16 +202,16 @@ onClickOutside(onClickOutsideRef, event => {
</div>
</div>
</div>
<div v-if="suggestionMenu || cart" :class="['fixed inset-0 ', cart ? 'z-40' : 'z-30']">
<div v-if="suggestionMenu || cartModal" :class="['fixed inset-0 ', cartModal ? 'z-40' : 'z-30']">
<div class="w-full h-full bg-black/30 backdrop-blur-lg"></div>
</div>
<button
v-if="cart"
v-if="cartModal"
class="hover:bg-white/65 dark:hover:bg-white/10 transition shadow-2xl mt-4 mx-5 items-center justify-center min-w-12 min-h-12 rounded-[2rem] right-0 fixed flex z-50 bg-white/85 dark:bg-black/30 dark:border dark:border-white/10 cart-button-bezel backdrop-blur-lg">
<UIcon class="text-[#5f5f5f] dark:text-[#b7b7b7]" name="i-iconamoon-close" size="26" />
</button>
<Transition name="dropdown">
<Cart v-if="cart" ref="onClickOutsideRef" />
<Cart v-if="cartModal" ref="onClickOutsideRef" />
</Transition>
</template>

Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
47 changes: 47 additions & 0 deletions app/components/Cart.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script setup>
const { cart, handleRemoveFromCart, removeFromCartButtonStatus } = useCart();
const { order } = useCheckout();
</script>

<template>
<div
class="select-none mx-5 shadow-2xl mt-20 rounded-[2rem] right-0 fixed flex z-50 bg-white/85 dark:bg-black/85 dark:border dark:border-white/10 cart-button-bezel backdrop-blur-lg overflow-hidden">
<Transition name="fade" mode="out-in">
<PaymentSuccessful v-if="order?.orderNumber && !cart.length" />
<div v-else-if="cart.length" class="flex w-full h-full">
<div class="w-80 relative">
<div class="absolute h-full w-full overflow-auto">
<div v-for="product in cart.slice().reverse()" :key="product.key" class="flex bg-black/5 dark:bg-white/10 m-3 p-3 gap-3 rounded-3xl items-center group relative">
<NuxtImg :src="product.variation.node.image.sourceUrl" class="w-24 h-28 object-cover shadow-md rounded-2xl" />
<div class="flex-1 gap-1 flex flex-col">
<div class="font-medium text-sm line-clamp-2 overflow-hidden text-ellipsis">{{ product.product.node.name }}</div>
<div class="font-bold">${{ Number(product.variation.node.salePrice).toFixed(2) }}</div>
<div class="flex-wrap text-neutral-600 dark:text-neutral-300 items-baseline text-xs gap-1 flex-row flex">
<p>Originally:</p>
<p class="line-through">${{ Number(product.variation.node.regularPrice).toFixed(2) }}</p>
<p class="text-alizarin-crimson-700">-{{ ((1 - product.variation.node.salePrice / product.variation.node.regularPrice) * 100).toFixed(0) }}%</p>
</div>
<div class="text-xs font-medium text-neutral-600 dark:text-neutral-300">
Size: {{ product.variation.attributes.map(attr => attr.value.toUpperCase()).join(', ') }} • Qty: {{ product.quantity }}
</div>
</div>
<button
@click="handleRemoveFromCart(product.key)"
class="absolute opacity-0 group-hover:opacity-100 -top-1 -right-1 transition bg-red-700 flex p-1 items-center justify-center rounded-full hover:bg-red-500 active:scale-95">
<UIcon :name="removeFromCartButtonStatus === 'remove' ? 'i-iconamoon-trash-light' : 'i-svg-spinners-90-ring-with-bg'" size="18" class="text-white" />
</button>
</div>
</div>
</div>
<Checkout />
</div>
<EmptyCart v-else />
</Transition>
</div>
</template>

<style lang="postcss">
.cart-button-bezel {
box-shadow: inset 0 -1px 1px 0 rgba(0, 0, 0, 0.2), inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
}
</style>
97 changes: 97 additions & 0 deletions app/components/Checkout.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<script setup>
const { userDetails, checkoutStatus, handleCheckout } = useCheckout();
const { cart } = useCart();
</script>

<template>
<div class="w-96 h-full bg-black/5 dark:bg-white/10 my-3 mr-3 p-3 rounded-3xl">
<div class="text-xl font-bold px-2 mb-3">Checkout</div>
<form @submit.prevent="handleCheckout" class="flex flex-col items-center justify-center">
<div class="grid grid-cols-2 gap-3 billing">
<div class="col-span-full">
<input required v-model="userDetails.email" placeholder="Email address" name="email" type="email" class="" />
</div>
<div class="col-span-1">
<input required v-model="userDetails.firstName" placeholder="First name" name="first-name" type="text" />
</div>
<div class="col-span-1">
<input required v-model="userDetails.lastName" placeholder="Last name" name="last-name" type="text" />
</div>
<div class="col-span-1">
<input required v-model="userDetails.phone" placeholder="Phone number" name="phone" type="text" />
</div>
<div class="col-span-1">
<input required v-model="userDetails.city" placeholder="City" name="city" type="text" />
</div>
<div class="col-span-full">
<textarea required v-model="userDetails.address1" placeholder="Address" name="address" rows="2"></textarea>
</div>
</div>
<div class="text-sm font-semibold p-4 text-neutral-600 dark:text-neutral-400">
Paying a total of ${{ cart.reduce((total, item) => total + parseFloat(item.variation.node.salePrice), 0).toFixed(2) }} for {{ cart.length }} products.
</div>
<button
type="submit"
:disabled="checkoutStatus !== 'order'"
class="pay-button-bezel w-full h-12 rounded-xl relative font-semibold text-white dark:text-black text-lg flex justify-center items-center">
<Transition name="slide-up">
<div v-if="checkoutStatus === 'order'" class="absolute">Pay ${{ cart.reduce((total, item) => total + parseFloat(item.variation.node.salePrice), 0).toFixed(2) }}</div>
<UIcon v-else-if="checkoutStatus === 'processing'" class="absolute" name="i-svg-spinners-90-ring-with-bg" size="22" />
</Transition>
</button>
<div class="text-xs font-medium p-4 flex gap-1 items-end text-neutral-400 dark:text-neutral-600">
<UIcon name="i-iconamoon-lock-fill" size="18" />
<div>Your payment is secured by Stripe</div>
</div>
</form>
</div>
</template>

<style lang="postcss">
:root {
--background: #fff;
--border: #ccc;
}
.dark {
--background: #000;
--border: #999;
}
input:-webkit-autofill,
textarea:-webkit-autofill,
select:-webkit-autofill {
-webkit-box-shadow: 0 0 0px 1000px var(--background, #fff) inset !important;
box-shadow: 0 0 0px 1000px var(--background, #fff) inset !important;
border-color: var(--border) !important;
}
.billing input,
.billing textarea {
@apply block bg-white/80 dark:bg-black/20 dark:border-white/20 w-full shadow font-semibold border-2 border-transparent transition hover:border-black dark:hover:border-white rounded-2xl py-3 px-4 text-black dark:text-white placeholder:text-neutral-400 text-sm leading-6 focus-visible:outline-none focus-visible:border-black focus-visible:dark:border-white;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 250ms;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
textarea {
resize: none;
}
.pay-button-bezel {
box-shadow: 0 0 0 var(--button-outline, 0px) rgba(92, 222, 131, 0.3), inset 0 -1px 1px 0 rgba(0, 0, 0, 0.25), inset 0 1px 0 0 rgba(255, 255, 255, 0.3),
0 1px 1px 0 rgba(0, 0, 0, 0.3);
@apply bg-[#23a26d] dark:bg-[#40d195] outline-none tracking-[-0.125px] transition scale-[var(--button-scale,1)] duration-200;
&:hover {
@apply brightness-110;
}
&:active {
--button-outline: 4px;
--button-scale: 0.975;
}
}
</style>
File renamed without changes.
33 changes: 33 additions & 0 deletions app/components/PaymentSuccessful.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<script setup>
const { order } = useCheckout();
</script>

<template>
<div class="w-96 h-full rounded-3xl">
<div class="flex flex-col items-center justify-center mb-6 mt-8 gap-1">
<div class="bg-green-500/20 dark:bg-green-700/20 flex rounded-full p-3 mb-1">
<UIcon name="i-iconamoon-check-circle-1-fill" size="46" class="text-[#23a26d] dark:text-[#40d195] shadow-md" />
</div>
<div class="text-lg font-semibold">Payment Success!</div>
<div class="text-sm text-neutral-500 dark:text-neutral-300">Your payment was successful.</div>
</div>
<div class="bg-black/5 dark:bg-white/10 rounded-2xl p-5 m-5 gap-2 flex flex-col font-light text-sm capitalize">
<div class="flex justify-between items-center">
<span class="text-neutral-400">Amount:</span>
<span class="font-semibold text-lg">{{ order.total }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-neutral-400">Order Number:</span>
<span class="font-semibold">#{{ order.orderNumber }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-neutral-400">Date:</span>
<span class="font-semibold">{{ useDateFormat(order.date, 'MMMM DD, YYYY') }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-neutral-400">Payment method:</span>
<span class="font-semibold">{{ order.paymentMethodTitle }}</span>
</div>
</div>
</div>
</template>
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
11 changes: 8 additions & 3 deletions composables/store.js → app/composables/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,19 @@ export function getProduct(slug, sku) {

//Mutation functions

async function fetchGraphQLMutation(query, variables = {}) {
const { $graphql } = useNuxtApp();
return await $graphql.default.request(query, variables, { 'woocommerce-session': localStorage.getItem('woocommerce-session') });
}

export function addToCart(input) {
return fetchGraphQL(addToCartMutation, { input });
return fetchGraphQLMutation(addToCartMutation, { input });
}

export function updateItemQuantities(input) {
return fetchGraphQL(updateItemQuantitiesMutation, { input });
return fetchGraphQLMutation(updateItemQuantitiesMutation, { input });
}

export function checkout(input) {
return fetchGraphQL(checkoutMutation, { input });
return fetchGraphQLMutation(checkoutMutation, { input });
}
50 changes: 50 additions & 0 deletions app/composables/useCart.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export const useCart = () => {
const cart = useState('cart', () => []);
const addToCartButtonStatus = ref('add');
const removeFromCartButtonStatus = ref('remove');

const handleAddToCart = productId => {
addToCartButtonStatus.value = 'loading';

addToCart({ productId })
.then(res => {
updateCart([...cart.value, res.addToCart.cartItem]);
addToCartButtonStatus.value = 'added';
setTimeout(() => (addToCartButtonStatus.value = 'add'), 2000);
})
.catch(err => {
addToCartButtonStatus.value = 'add';
const errorMessage = err.response.errors[0].message
.replace(/<a[^>]*>(.*?)<\/a>/g, '')
.replace(/&mdash;/g, '—')
.trim();
push.error(errorMessage);
});
};

const handleRemoveFromCart = key => {
removeFromCartButtonStatus.value = 'loading';
updateItemQuantities({ items: [{ key, quantity: 0 }] }).then(() => {
removeFromCartButtonStatus.value = 'remove';
updateCart(cart.value.filter(item => item.key !== key));
});
};

const updateCart = newCart => {
cart.value = newCart;
localStorage.setItem('cart', JSON.stringify(newCart));
};

onMounted(() => {
const storedCart = localStorage.getItem('cart');
if (storedCart) cart.value = JSON.parse(storedCart);
});

return {
cart,
handleAddToCart,
addToCartButtonStatus,
handleRemoveFromCart,
removeFromCartButtonStatus,
};
};
21 changes: 21 additions & 0 deletions app/composables/useCheckout.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export const useCheckout = () => {
const { cart } = useCart();
const order = useState('order', () => {});
const userDetails = ref({ email: '', firstName: '', lastName: '', phone: '', city: '', address1: '' });
const checkoutStatus = ref('order');

const handleCheckout = async () => {
checkoutStatus.value = 'processing';
const checkoutData = {
billing: { ...userDetails.value },
paymentMethod: 'cod',
};

await checkout(checkoutData).then(res => {
cart.value = [];
localStorage.setItem('cart', JSON.stringify(cart.value));
order.value = res.checkout.order;
});
};
return { order, userDetails, checkoutStatus, handleCheckout };
};
File renamed without changes.
13 changes: 13 additions & 0 deletions gql/mutations/addToCart.ts → app/gql/mutations/addToCart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,23 @@ export const addToCartMutation = gql`
addToCart(input: $input) {
cartItem {
key
quantity
product {
node {
sku
slug
name
}
}
variation {
node {
name
databaseId
salePrice(format: RAW)
regularPrice(format: RAW)
image {
sourceUrl(size: WOOCOMMERCE_THUMBNAIL)
}
}
attributes {
value
Expand Down
8 changes: 6 additions & 2 deletions gql/mutations/checkout.ts → app/gql/mutations/checkout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ import { gql } from 'nuxt-graphql-request/utils';
export const checkoutMutation = gql`
mutation Checkout($input: CheckoutInput!) {
checkout(input: $input) {
result
redirect
order {
total
orderNumber
date
paymentMethodTitle
}
}
}
`;
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const getProductQuery = gql`
}
variations(where: { orderby: { field: NAME, order: DESC } }) {
nodes {
databaseId
stockStatus
stockQuantity
attributes {
Expand Down
File renamed without changes.
File renamed without changes.
Loading

0 comments on commit 60147b7

Please sign in to comment.