Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(vue-demo-store): customized product example #451

Merged
merged 44 commits into from
Feb 15, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
9985f56
feat(vue-demo-store): customized product example
mkucmus Nov 3, 2023
a285a95
fix: shared state
mkucmus Nov 6, 2023
d248f49
chore: cleanup
mkucmus Nov 6, 2023
a9fbf36
chore: unify state object
mkucmus Nov 6, 2023
f144ef2
fix: type consistency
mkucmus Nov 7, 2023
c263607
Merge branch 'main' into feat/customized-products-example
mkucmus Nov 7, 2023
7998174
docs: added integration subchapter
mkucmus Nov 8, 2023
d7c6d32
chore: changeset
mkucmus Nov 8, 2023
ba1a1db
chore: changes after CR
mkucmus Nov 8, 2023
961e31e
fix: docs link
mkucmus Nov 8, 2023
b4e821e
fix: remove an old link
mkucmus Nov 8, 2023
a27a2e7
feat: allow to upload images only
mkucmus Nov 8, 2023
e17fbdb
docs: add known issues
mkucmus Nov 8, 2023
0de91e3
Merge branch 'main' into feat/customized-products-example
mkucmus Nov 9, 2023
c109139
Merge branch 'main' into feat/customized-products-example
mkucmus Dec 6, 2023
ec6b301
fix: types
mkucmus Dec 6, 2023
5888619
Merge branch 'main' into feat/customized-products-example
patzick Dec 6, 2023
824457e
feat: create new example for customized products
mkucmus Dec 8, 2023
e30113f
feat: image upload handling
mkucmus Dec 12, 2023
94bdd51
feat: improvements
mkucmus Dec 15, 2023
0288b7e
fix: image upload
mkucmus Dec 18, 2023
14a42a2
chore: lockfile
mkucmus Dec 18, 2023
3858af1
Merge branch 'main' into feat/customized-products-example
mkucmus Dec 18, 2023
b6edee7
fix: types
mkucmus Dec 18, 2023
51147a1
Merge branch 'main' into feat/customized-products-example
mkucmus Jan 3, 2024
769fa52
Merge branch 'main' into feat/customized-products-example
mkucmus Jan 12, 2024
2b72264
chore: fix build
mkucmus Jan 12, 2024
631a712
fix: lock file update
mkucmus Jan 12, 2024
db93004
chore: cleanup
mkucmus Jan 12, 2024
d93e376
fix: tests
mkucmus Jan 12, 2024
297e354
Merge branch 'main' into feat/customized-products-example
mkucmus Jan 18, 2024
31def36
chore: lock update
mkucmus Jan 18, 2024
c0c7a31
Merge branch 'main' into feat/customized-products-example
patzick Feb 1, 2024
f26e8fe
chore: small cleanup
patzick Feb 1, 2024
09afed6
chore: more cleanup
patzick Feb 1, 2024
771b336
Merge branch 'main' into feat/customized-products-example
mkucmus Feb 6, 2024
a029fb7
Merge branch 'feat/customized-products-example' of github.com:shopwar…
mkucmus Feb 6, 2024
764b9fc
fix(example): test coverage and cleanup
mkucmus Feb 6, 2024
26c562f
fix(examples): cleanup
mkucmus Feb 6, 2024
15c075b
fix(examples): cleanup
mkucmus Feb 6, 2024
2dd444b
fix(vue-demo-store): lint
mkucmus Feb 6, 2024
97d01b8
tests(api-client-next): early return for listed HTTP methods
mkucmus Feb 6, 2024
2757366
chore: update benchmark to test all transformation cases
patzick Feb 6, 2024
db6cbfc
Merge branch 'main' into feat/customized-products-example
patzick Feb 15, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/tough-months-breathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"vue-demo-store": patch
"docs": patch
---

Compatibility with Custom Products extension
4 changes: 4 additions & 0 deletions apps/docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,10 @@ export const sidebar = [
link: "/resources/integrations/multi-cms",
},
{ text: "Strapi", link: "/resources/integrations/strapi/" },
{
text: "Custom Products extension",
link: "/resources/integrations/custom-products",
mkucmus marked this conversation as resolved.
Show resolved Hide resolved
},
],
},
],
Expand Down
106 changes: 106 additions & 0 deletions apps/docs/src/resources/integrations/custom-products.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
---
head:
- - meta
- name: og:title
content: "Integrations: Custom Products - Shopware Frontends"
- - meta
- name: og:description
content: "Example of integration with Custom Products extension"
- - meta
- name: og:image
content: "https://frontends-og-image.vercel.app/Integration:%20**Custom%20Products**?fontSize=100px"
---

# Custom Products extension

The example explains how **Custom Products** feature is implemented in `vue-demo-store` template (already done), but also can be used as a guide how to deal with the process in any project.

:::warning Custom Products for Shopware 6 is an extension that is part of the Shopware Rise plan.
[Read more](https://docs.shopware.com/en/shopware-6-en/extensions/customproducts).
:::

## Logic: Composable function

See [the source code](https://github.com/shopware/frontends/blob/main/templates/vue-demo-store/composables/useProductCustomizedProductConfigurator.ts) of `useProductCustomizedProductConfigurator` composable function.

The composable is a main place to keep the logic related to _custom product_ features:

- adds TypeScript types
- stores the state
- extracts the custom product's specific data
- exposes method for adding to cart
- serializes the state to be in a correct format for the request's payload (adding to cart)

### Example of usage:

:::warning
Works only if the `useProduct` is fulfilled and the product data is known. Typically on Product Details Page, when the product context is provided.

Visit the [useProduct](../../packages/composables/useProduct) reference to see more details.
:::

```ts
// useProductCustomizedProductConfigurator is autoimported
// in vue-demo-store template as it's located in ~/composables
const {
isActive, // indicates whether product is empowered by Custom Products extension and active
customizedProduct, // returns the custom product's template data
state, // state to be used in option selector / forms
addToCart, // triggers add to cart action (refreshCart() action invoked afterwards)
handleFileUpload, // uploads an image, then gets mediaId from API and assigns it to the state
} = useProductCustomizedProductConfigurator();
```

## Presentation: Vue component

See [the source code](https://github.com/shopware/frontends/blob/main/templates/vue-demo-store/components/product/ProductCustomizedProductConfigurator.vue) of the `ProductCustomizedProductConfigurator` Vue component.

The component is responsible for:

- Displaying product options in any type: text field, image upload, select, color select, image select (this one has to be fixed in the core to get the URL's of the images)
- Showing corresponding additional price and currency of an option

## Implementation

Add the mentioned component in a template. For instance in `<ProductStatic/>` for templates that not come from CMS:

```html{9}
<!-- part of templates/vue-demo-store/components/product/ProductStatic.vue -->
<!-- Options -->
<div class="mt-4 lg:mt-0 lg:row-span-3">
<h2 class="sr-only">Product information</h2>
<div class="product-variants mt-10">
<ProductPrice :product="product" />
<ProductUnits :product="product" class="text-sm" />
<ProductVariantConfigurator @change="handleVariantChange" />
<ProductCustomizedProductConfigurator /> <!-- ADDED -->
<ProductAddToCart :product="product" />
</div>
</div>
```

Overwrite a logic in `<ProductAddToCart/>` (or any other responsible for adding a product to cart in your template):

```ts{3-6,9-10}
// part of templates/vue-demo-store/components/product/ProductAddToCart.vue;
// the <script setup lang="ts"> section
const {
addToCart: customizedProductAddToCart,
isActive: isCustomizedProductActive,
} = useProductCustomizedProductConfigurator();

const addToCartProxy = async () => {
if (isCustomizedProductActive.value) {
await customizedProductAddToCart();
} else {
await addToCart();
}
...
```

Used composable function allows to use `addToCart()` method and `isActive` computed property. Both are described in "Example of usage" chapter above.

There was a condition added to use a different method to add to cart a product if the product is enhanced by Custom Product template ([how to set it up](https://docs.shopware.com/en/shopware-6-en/extensions/customproducts)):

- if the product has a Custom Product template, then use `customizedProductAddToCart()` method.
- otherwise, don't change the adding to cart behavior and use the default one
4 changes: 4 additions & 0 deletions apps/docs/src/resources/integrations/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,7 @@ Streamline your operations and enhance your user experience with these comprehen
## Payments

<PageRef title="Paypal Express" sub="Custom payment flow based on PayPal Express Checkout " page="../../getting-started/e-commerce/custom-payment.html" />

## Shopware Extensions

<PageRef title="Custom Products" sub="Example of integration with Custom Products extension" page="./custom-products" />
4 changes: 1 addition & 3 deletions packages/composables/src/useCart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,7 @@ export function useCartFunction(): UseCartReturn {
const count = computed(() => {
return cartItems.value.reduce(
(accumulator: number, lineItem: LineItem) =>
lineItem.type === "product"
? lineItem.quantity + accumulator
: accumulator,
lineItem.good === true ? lineItem.quantity + accumulator : accumulator,
patzick marked this conversation as resolved.
Show resolved Hide resolved
0,
);
});
Expand Down
7 changes: 7 additions & 0 deletions packages/composables/src/useCartItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export type UseCartItemReturn = {
* Determines if the current item is a promotion
*/
isPromotion: ComputedRef<boolean>;
/**
* Determines if the current item can be removed from cart
*/
isRemovable: ComputedRef<boolean>;
/**
* Stock information for the current item
*/
Expand Down Expand Up @@ -115,6 +119,8 @@ export function useCartItem(cartItem: Ref<LineItem>): UseCartItemReturn {

const isPromotion = computed(() => cartItem.value.type === "promotion");

const isRemovable = computed(() => cartItem.value.removable);
patzick marked this conversation as resolved.
Show resolved Hide resolved

async function removeItem() {
const newCart = await removeCartItem(cartItem.value.id, apiInstance);
await refreshCart(newCart);
Expand Down Expand Up @@ -174,5 +180,6 @@ export function useCartItem(cartItem: Ref<LineItem>): UseCartItemReturn {
itemImageThumbnailUrl,
isProduct,
isPromotion,
isRemovable,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ export type Product = {
displayGroup: string;
downloads: any;
ean: string | null;
extensions: [];
extensions: unknown;
height: number | null;
id: string;
isCloseout: boolean | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const {
itemTotalPrice,
itemQuantity,
isPromotion,
isRemovable,
changeItemQuantity,
} = useCartItem(cartItem);

Expand Down Expand Up @@ -122,7 +123,7 @@ const removeCartItem = async () => {
/>
<div class="flex">
<button
v-if="!isPromotion"
v-if="!isPromotion && isRemovable"
type="button"
class="font-medium text-brand-dark bg-transparent"
:class="{ 'text-gray-500': isLoading }"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,17 @@ const { product } = toRefs(props);
const { getErrorsCodes } = useCartNotification();
const { t } = useI18n();
const { addToCart, quantity } = useAddToCart(product);
const {
addToCart: customizedProductAddToCart,
isActive: isCustomizedProductActive,
} = useProductCustomizedProductConfigurator();

const addToCartProxy = async () => {
await addToCart();
if (isCustomizedProductActive.value) {
await customizedProductAddToCart();
patzick marked this conversation as resolved.
Show resolved Hide resolved
} else {
await addToCart();
}
getErrorsCodes()?.forEach((element) => {
pushError(t(`errors.${element.messageKey}`, { ...element }));
});
Expand Down
16 changes: 14 additions & 2 deletions templates/vue-demo-store/components/product/ProductCard.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<script setup lang="ts">
import type { BoxLayout, DisplayMode } from "@shopware-pwa/composables-next";
import {
patzick marked this conversation as resolved.
Show resolved Hide resolved
type BoxLayout,
type DisplayMode,
} from "@shopware-pwa/composables-next";
import {
getProductName,
getProductThumbnailUrl,
Expand Down Expand Up @@ -31,6 +34,10 @@ const props = withDefaults(
);
const { product } = toRefs(props);
const { addToCart, isInCart, count } = useAddToCart(product);
const {
addToCart: customizedProductAddToCart,
isActive: isCustomizedProductActive,
} = useProductCustomizedProductConfigurator();

const { addToWishlist, removeFromWishlist, isInWishlist } =
useProductWishlist(product);
Expand Down Expand Up @@ -61,7 +68,12 @@ const toggleWishlistProduct = async () => {
};

const addToCartProxy = async () => {
await addToCart();
if (isCustomizedProductActive.value) {
await customizedProductAddToCart();
} else {
await addToCart();
}

getErrorsCodes()?.forEach((element) => {
pushError(t(`errors.${element.messageKey}`, { ...element }));
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<script setup lang="ts">
const { customizedProduct, state, handleFileUpload } =
useProductCustomizedProductConfigurator();
const { currency } = useSessionContext();

const customizedProductOptions = computed(() => {
const options = customizedProduct.value?.options.map((option) => ({
...option,
// prepend options by "No selection"
values: [
{
id: undefined,
displayName: "No selection",
value: null,
price: [{ gross: "0" }],
},
...option.values,
],
}));

return options;
});

const removeUploadedImage = (optionId: string) => {
delete state.value[optionId];
};
</script>
<template>
<div class="flex flex-col">
<h3>{{ customizedProduct?.translated.displayName }}</h3>
<p class="mb-3 text-gray-500 dark:text-gray-400">
{{ customizedProduct?.translated.description }}
</p>
<hr />
<div class="flex flex-col">
<div v-for="option in customizedProductOptions" :key="option.id">
{{ option.translated.displayName }}
<div
v-if="['select', 'colorselect', 'imageselect'].includes(option.type)"
:id="option.id"
:key="option.id"
class="mb-6 mt-4"
>
<select
:id="option.id"
:key="option.id"
v-model="state[option.id]"
:selected="null"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
>
<option
v-for="value in option.values"
:key="value.id"
:value="value.id"
class="p-2"
:style="{ 'background-color': value.value?._value }"
>
{{ value.displayName }}
<span v-if="+value.price?.[0]?.gross > 0"
>+{{ value?.price?.[0]?.gross }} {{ currency?.symbol }}</span
>
</option>
</select>
</div>

<div v-if="option.type == 'textfield'" :id="option.id" class="mb-6">
<input
v-model="state[option.id]"
type="text"
:placeholder="option.placeholder || ''"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
/>
</div>
<div v-if="option.type == 'imageupload'" :id="option.id" class="mb-6">
<p class="mb-3 text-gray-500 dark:text-gray-400">
{{ option.description }}
</p>
<div
v-if="!state[option.id]"
class="flex items-center justify-center w-full mt-4"
>
<label
for="dropzone-file"
class="flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50 dark:hover:bg-bray-800 dark:bg-gray-700 hover:bg-gray-100 dark:border-gray-600 dark:hover:border-gray-500 dark:hover:bg-gray-600"
>
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<svg
class="w-8 h-8 mb-4 text-gray-500 dark:text-gray-400"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 20 16"
>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 13h3a3 3 0 0 0 0-6h-.025A5.56 5.56 0 0 0 16 6.5 5.5 5.5 0 0 0 5.207 5.021C5.137 5.017 5.071 5 5 5a4 4 0 0 0 0 8h2.167M10 15V6m0 0L8 8m2-2 2 2"
/>
</svg>
<p class="mb-2 text-sm text-gray-500 dark:text-gray-400">
<span class="font-semibold">Click to upload</span> or drag and
drop
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
SVG, PNG, JPG or GIF (Max. 1 file(s), max. 10 MB per file)
</p>
</div>
<input
id="dropzone-file"
type="file"
class="hidden"
@change="handleFileUpload($event, option.id)"
/>
</label>
</div>
<div v-else>
<span class="italic">{{
(state[option.id] as any)?.media?.filename
}}</span>
<button
type="button"
class="ml-4 px-2 py-1 text-xs font-medium text-center text-white bg-red-700 rounded-lg hover:bg-red-800 focus:ring-4 focus:outline-none focus:ring-red-300 dark:bg-red-600 dark:hover:bg-red-700 dark:focus:ring-red-800"
@click="removeUploadedImage(option.id)"
>
x
</button>
</div>
</div>
</div>
</div>
</div>
</template>
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const handleVariantChange = (val: Product) => {
<ProductPrice :product="product" />
<ProductUnits :product="product" class="text-sm" />
<ProductVariantConfigurator @change="handleVariantChange" />
<ProductCustomizedProductConfigurator />
<ProductAddToCart :product="product" />
</div>
</div>
Expand Down
Loading
Loading