mirror of
https://github.com/vercel/commerce.git
synced 2025-05-23 18:06:58 +00:00
Gherkin use case for cart features with Cypress + Cumcumber + typescript
Gherkin use case for cart features with Cypress + Cumcumber + typescript
This commit is contained in:
parent
f22b1ead60
commit
5a17eaf211
7
.cypress-cucumber-preprocessorrc.json
Normal file
7
.cypress-cucumber-preprocessorrc.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"stepDefinitions": [
|
||||||
|
"cypress/e2e/**/[filepath].ts",
|
||||||
|
"cypress/e2e/common-step-definitions/*.ts",
|
||||||
|
"cypress/support/step_definitions/*.ts"
|
||||||
|
]
|
||||||
|
}
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -36,3 +36,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
#cursor
|
||||||
|
.cursorrules
|
@ -33,6 +33,7 @@ Vercel is happy to partner and work with any commerce provider to help them get
|
|||||||
Integrations enable upgraded or additional functionality for Next.js Commerce
|
Integrations enable upgraded or additional functionality for Next.js Commerce
|
||||||
|
|
||||||
- [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/))
|
- [Orama](https://github.com/oramasearch/nextjs-commerce) ([Demo](https://vercel-commerce.oramasearch.com/))
|
||||||
|
|
||||||
- Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration.
|
- Upgrades search to include typeahead with dynamic re-rendering, vector-based similarity search, and JS-based configuration.
|
||||||
- Search runs entirely in the browser for smaller catalogs or on a CDN for larger.
|
- Search runs entirely in the browser for smaller catalogs or on a CDN for larger.
|
||||||
|
|
||||||
|
85
Test-Design.md
Normal file
85
Test-Design.md
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
# Component Hierarchy and Data-Test Attributes
|
||||||
|
|
||||||
|
Voici un schéma Mermaid qui représente les composants et les hiérarchies des attributs data-test pour le composant `ThreeItemGrid`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
%%{init: {'theme': 'dark'}}%%
|
||||||
|
|
||||||
|
graph TD
|
||||||
|
|
||||||
|
%% Utilisation d'un vert plus foncé (#339966) avec du texte blanc (#fff) pour améliorer le contraste
|
||||||
|
classDef grid-item-class fill:#339966,stroke:#aaa,stroke-width:2px,color:#fff;
|
||||||
|
|
||||||
|
%% Bleu foncé (#336699) avec texte blanc (#fff) pour les conteneurs
|
||||||
|
classDef container-class fill:#336699,stroke:#aaa,stroke-width:2px,color:#fff;
|
||||||
|
|
||||||
|
%% Rouge foncé (#cc3333) avec texte blanc (#fff) pour les composants React
|
||||||
|
classDef react-component-class fill:#cc3333,stroke:#aaa,stroke-width:2px,color:#fff;
|
||||||
|
|
||||||
|
%% Subgraph représentant la structure globale de la grille
|
||||||
|
subgraph ThreeItemGrid
|
||||||
|
style ThreeItemGrid fill:#333,stroke:#aaa,stroke-width:2px;
|
||||||
|
|
||||||
|
%% Grid Item avec taille configurable
|
||||||
|
ThreeItemGridItem[⚛️ Three Item Grids]
|
||||||
|
class ThreeItemGridItem react-component-class
|
||||||
|
|
||||||
|
GridItem[🗂️ data-test: three-item-grid]
|
||||||
|
ThreeItemGridItem --> GridItem
|
||||||
|
class GridItem container-class
|
||||||
|
|
||||||
|
GridItemSize[🔍 data-test: grid-item <br>⬛ full / ⬜ half]
|
||||||
|
GridItem --> GridItemSize
|
||||||
|
class GridItemSize grid-item-class
|
||||||
|
|
||||||
|
%% Grid Product Link pour GridItemSize
|
||||||
|
GridItemSize --> GridProductLink[🔍 data-test: grid-product-link]
|
||||||
|
class GridProductLink grid-item-class
|
||||||
|
|
||||||
|
GridProductLink --> GridTileImage[⚛️ GridTileImage]
|
||||||
|
class GridTileImage react-component-class
|
||||||
|
|
||||||
|
GridTileImage --> GridTileContainer[🗂️ data-test: grid-tile-container]
|
||||||
|
class GridTileContainer container-class
|
||||||
|
|
||||||
|
GridTileContainer --> GridTileImageItem[🔍 data-test: grid-tile-image]
|
||||||
|
class GridTileImageItem grid-item-class
|
||||||
|
|
||||||
|
GridTileContainer --> Label[⚛️ Label]
|
||||||
|
class Label react-component-class
|
||||||
|
|
||||||
|
Label --> LabelContainer[🗂️ data-test: label-container]
|
||||||
|
class LabelContainer container-class
|
||||||
|
|
||||||
|
LabelContainer --> LabelContentWrapper[🗂️ data-test: label-content-wrapper]
|
||||||
|
class LabelContentWrapper container-class
|
||||||
|
|
||||||
|
LabelContentWrapper --> LabelTitleText[🔍 data-test: label-title-text]
|
||||||
|
class LabelTitleText grid-item-class
|
||||||
|
|
||||||
|
LabelContentWrapper --> Price[⚛️ Price]
|
||||||
|
class Price react-component-class
|
||||||
|
|
||||||
|
Price --> PriceAmount[🔍 data-test: price-amount]
|
||||||
|
class PriceAmount grid-item-class
|
||||||
|
|
||||||
|
Price --> PriceCurrencyCode[🔍 data-test: price-currency-code]
|
||||||
|
class PriceCurrencyCode grid-item-class
|
||||||
|
end
|
||||||
|
|
||||||
|
%% Subgraph pour la légende
|
||||||
|
subgraph Légende
|
||||||
|
direction LR
|
||||||
|
ReactComp[⚛️ Composants React]
|
||||||
|
class ReactComp react-component-class
|
||||||
|
|
||||||
|
DataTestCont[🗂️ Conteneur data-test]
|
||||||
|
class DataTestCont container-class
|
||||||
|
|
||||||
|
TestableElem[🔍 Éléments testables]
|
||||||
|
class TestableElem grid-item-class
|
||||||
|
end
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
[](https://mermaid-js.github.io/mermaid-live-editor/edit#pako:eNqVVs1u20YQfpUFA0ENIKaWlcoWWwRo5aII4ACBraJAKx9W5FBaeLlLLJeJHUGX3nooeilQoCnQs4E8QO96E79A-wid3RUp_oh2woNE7sx837czQ-6svVBG4AVer7dmgumArPt6BQn0A9KPqLrubza93lzMxVLRdEVmZ-a-1yPfa8ZZRjWTgkT9XJA3oDRJeZ6RWIpwe0c-ezIaTSbj8VNC30BIopxouNFAFpyKEK1xHD8lqcwVocn2jjOpQBEOJJRCK5ppmIuQ0yw7g5gsFYt8piHx7RKJGefBDn-QaSWvIXhCKd3d-29ZpFfBcXozCCWXKjBkX-6Uf8Mhr2ocjyeTncYugRwyKwsE5Cqr6DKLlAlQDV0G81N1Xch8CXthYTjC66OEJanMqNAZuQAa6oo8ZZ596yBA6JpIh_-pIi_zhWsEBana3mWIisyEU4LBeahzBWTJ5YJiISMw61g6zrGWWRE5WymAl1jL77Coc0HwyvQt-tcMZSoflmh1GQjUZsNM_C5n1BCbEsVsmSu6MCqMa43H_P90_-f7f__5zRkcgjFlV87fZa0VdTi7LsT9lvj__f3Hz4Ygopr6GjJ8zbSBcy1tmvuqQxrx_RclTlVO6dBowUP8l-wdoIbff60KKF8p8tVCvbj_8J7EOefkc3L_4S-yojy-qoPUlBjEQ2rMevNlbVbotZIRdgo5Z-LadXEbtYZXEO8CTdzh3aTOwefocdWUV4nuUth0K4hnjMPLhC6haJTaYouptHxch-zdq3zToqyHmsfqxw8w-GX1D6ooQdpdUuffO7b2vGvgA-m2AtjDKbCd80C62-TndAG8SLR9qKHblccT69xKvAezyY1HRybr0V1pbHjVaFHeD_jdSx_jNtt46xwPK9gDPSqj4lpqmTHNYYbnSKuYToM2dt8cNG36MrarlF3MrxULy7fGPtTA7crjxXRuJd7XicxFexupsfnUGtssLqhLf4NhmisFIryd4mzUwRPuXHwzP7XpqghtUhBR8zx1xzklfHu3RHP1wDwvlwxJxBSEduw6v3Ar9tSfYvqKRE8bE0FNXundlXfnfIYbnuF-TVHLxp0WM9A-HzXsalBXkxq7OYu_5cV3ZfsLbjoBI1fvjI2jtxLSkUxv4CWgEsoinGXXJnju2Tl27gV4i5pxpuRzby426EpzLS9vRegFOLTAwFM4eq28IKY8w6c8xd3BGaOY_qRcTan4UcqkCIGIaaleueHZztDWxQvW3o0XHB-Nnx0_H05Ono-GJ6fDk4F36wVHm4H3zkIMnx25a3h8OpycfjEab_4HDOwk4w)
|
@ -80,9 +80,12 @@ export default async function ProductPage({ params }: { params: { handle: string
|
|||||||
__html: JSON.stringify(productJsonLd)
|
__html: JSON.stringify(productJsonLd)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="mx-auto max-w-screen-2xl px-4">
|
<div className="mx-auto max-w-screen-2xl px-4" data-test="product-page">
|
||||||
<div className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 lg:flex-row lg:gap-8 dark:border-neutral-800 dark:bg-black">
|
<div
|
||||||
<div className="h-full w-full basis-full lg:basis-4/6">
|
className="flex flex-col rounded-lg border border-neutral-200 bg-white p-8 md:p-12 lg:flex-row lg:gap-8 dark:border-neutral-800 dark:bg-black"
|
||||||
|
data-test="product-details"
|
||||||
|
>
|
||||||
|
<div className="h-full w-full basis-full lg:basis-4/6" data-test="product-image">
|
||||||
<Suspense
|
<Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" />
|
<div className="relative aspect-square h-full max-h-[550px] w-full overflow-hidden" />
|
||||||
@ -97,7 +100,7 @@ export default async function ProductPage({ params }: { params: { handle: string
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="basis-full lg:basis-2/6">
|
<div className="basis-full lg:basis-2/6" data-test="product-description">
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<ProductDescription product={product} />
|
<ProductDescription product={product} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@ -116,18 +119,20 @@ async function RelatedProducts({ id }: { id: string }) {
|
|||||||
if (!relatedProducts.length) return null;
|
if (!relatedProducts.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-8">
|
<div className="py-8" data-test="product-related">
|
||||||
<h2 className="mb-4 text-2xl font-bold">Related Products</h2>
|
<h2 className="mb-4 text-2xl font-bold">Related Products</h2>
|
||||||
<ul className="flex w-full gap-4 overflow-x-auto pt-1">
|
<ul className="flex w-full gap-4 overflow-x-auto pt-1">
|
||||||
{relatedProducts.map((product) => (
|
{relatedProducts.map((product) => (
|
||||||
<li
|
<li
|
||||||
key={product.handle}
|
key={product.handle}
|
||||||
className="aspect-square w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5"
|
className="aspect-square w-full flex-none min-[475px]:w-1/2 sm:w-1/3 md:w-1/4 lg:w-1/5"
|
||||||
|
data-test="product-item"
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
className="relative h-full w-full"
|
className="relative h-full w-full"
|
||||||
href={`/product/${product.handle}`}
|
href={`/product/${product.handle}`}
|
||||||
prefetch={true}
|
prefetch={true}
|
||||||
|
data-test="product-item-link"
|
||||||
>
|
>
|
||||||
<GridTileImage
|
<GridTileImage
|
||||||
alt={product.title}
|
alt={product.title}
|
||||||
|
@ -12,14 +12,19 @@ export async function Carousel() {
|
|||||||
const carouselProducts = [...products, ...products, ...products];
|
const carouselProducts = [...products, ...products, ...products];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full overflow-x-auto pb-6 pt-1">
|
<div className="w-full overflow-x-auto pb-6 pt-1" data-test="carousel-container">
|
||||||
<ul className="flex animate-carousel gap-4">
|
<ul className="flex animate-carousel gap-4">
|
||||||
{carouselProducts.map((product, i) => (
|
{carouselProducts.map((product, i) => (
|
||||||
<li
|
<li
|
||||||
key={`${product.handle}${i}`}
|
key={`${product.handle}${i}`}
|
||||||
className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
|
className="relative aspect-square h-[30vh] max-h-[275px] w-2/3 max-w-[475px] flex-none md:w-1/3"
|
||||||
|
data-test={`carousel-item-${i}`}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href={`/product/${product.handle}`}
|
||||||
|
className="relative h-full w-full"
|
||||||
|
data-test="product-link"
|
||||||
>
|
>
|
||||||
<Link href={`/product/${product.handle}`} className="relative h-full w-full">
|
|
||||||
<GridTileImage
|
<GridTileImage
|
||||||
alt={product.title}
|
alt={product.title}
|
||||||
label={{
|
label={{
|
||||||
@ -30,6 +35,7 @@ export async function Carousel() {
|
|||||||
src={product.featuredImage?.url}
|
src={product.featuredImage?.url}
|
||||||
fill
|
fill
|
||||||
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
|
sizes="(min-width: 1024px) 25vw, (min-width: 768px) 33vw, 50vw"
|
||||||
|
data-test="tile-image"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
@ -8,15 +8,20 @@ import { redirect } from 'next/navigation';
|
|||||||
|
|
||||||
export async function addItem(prevState: any, selectedVariantId: string | undefined) {
|
export async function addItem(prevState: any, selectedVariantId: string | undefined) {
|
||||||
let cartId = cookies().get('cartId')?.value;
|
let cartId = cookies().get('cartId')?.value;
|
||||||
|
console.log('Cart ID before adding item:', cartId);
|
||||||
|
|
||||||
if (!cartId || !selectedVariantId) {
|
if (!cartId || !selectedVariantId) {
|
||||||
|
console.error('Cart ID or selected variant ID is missing');
|
||||||
return 'Error adding item to cart';
|
return 'Error adding item to cart';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('Attempting to add item to Shopify cart');
|
||||||
await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]);
|
await addToCart(cartId, [{ merchandiseId: selectedVariantId, quantity: 1 }]);
|
||||||
|
console.log('Item added to Shopify cart successfully');
|
||||||
revalidateTag(TAGS.cart);
|
revalidateTag(TAGS.cart);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
console.error('Error adding item to cart:', e);
|
||||||
return 'Error adding item to cart';
|
return 'Error adding item to cart';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
import { PlusIcon } from '@heroicons/react/24/outline';
|
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { addItem } from 'components/cart/actions';
|
|
||||||
import { useProduct } from 'components/product/product-context';
|
import { useProduct } from 'components/product/product-context';
|
||||||
import { Product, ProductVariant } from 'lib/shopify/types';
|
import { Product, ProductVariant } from 'lib/shopify/types';
|
||||||
import { useFormState } from 'react-dom';
|
|
||||||
import { useCart } from './cart-context';
|
import { useCart } from './cart-context';
|
||||||
|
import { addItem } from './actions';
|
||||||
|
|
||||||
function SubmitButton({
|
function SubmitButton({
|
||||||
availableForSale,
|
availableForSale,
|
||||||
@ -21,19 +20,20 @@ function SubmitButton({
|
|||||||
|
|
||||||
if (!availableForSale) {
|
if (!availableForSale) {
|
||||||
return (
|
return (
|
||||||
<button disabled className={clsx(buttonClasses, disabledClasses)}>
|
<button disabled className={clsx(buttonClasses, disabledClasses)} data-test="add-to-cart">
|
||||||
Out Of Stock
|
Out Of Stock
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(selectedVariantId);
|
// console.log(selectedVariantId);
|
||||||
if (!selectedVariantId) {
|
if (!selectedVariantId) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label="Please select an option"
|
aria-label="Please select an option"
|
||||||
disabled
|
disabled
|
||||||
className={clsx(buttonClasses, disabledClasses)}
|
className={clsx(buttonClasses, disabledClasses)}
|
||||||
|
data-test="add-to-cart"
|
||||||
>
|
>
|
||||||
<div className="absolute left-0 ml-4">
|
<div className="absolute left-0 ml-4">
|
||||||
<PlusIcon className="h-5" />
|
<PlusIcon className="h-5" />
|
||||||
@ -49,6 +49,7 @@ function SubmitButton({
|
|||||||
className={clsx(buttonClasses, {
|
className={clsx(buttonClasses, {
|
||||||
'hover:opacity-90': true
|
'hover:opacity-90': true
|
||||||
})}
|
})}
|
||||||
|
data-test="add-to-cart"
|
||||||
>
|
>
|
||||||
<div className="absolute left-0 ml-4">
|
<div className="absolute left-0 ml-4">
|
||||||
<PlusIcon className="h-5" />
|
<PlusIcon className="h-5" />
|
||||||
@ -62,27 +63,37 @@ export function AddToCart({ product }: { product: Product }) {
|
|||||||
const { variants, availableForSale } = product;
|
const { variants, availableForSale } = product;
|
||||||
const { addCartItem } = useCart();
|
const { addCartItem } = useCart();
|
||||||
const { state } = useProduct();
|
const { state } = useProduct();
|
||||||
const [message, formAction] = useFormState(addItem, null);
|
|
||||||
|
|
||||||
|
// Trouver le variant correspondant à l'état actuel du produit
|
||||||
const variant = variants.find((variant: ProductVariant) =>
|
const variant = variants.find((variant: ProductVariant) =>
|
||||||
variant.selectedOptions.every((option) => option.value === state[option.name.toLowerCase()])
|
variant.selectedOptions.every((option) => option.value === state[option.name.toLowerCase()])
|
||||||
);
|
);
|
||||||
const defaultVariantId = variants.length === 1 ? variants[0]?.id : undefined;
|
const defaultVariant = variants.length === 1 ? variants[0] : undefined;
|
||||||
const selectedVariantId = variant?.id || defaultVariantId;
|
const selectedVariant = variant || defaultVariant;
|
||||||
const actionWithVariant = formAction.bind(null, selectedVariantId);
|
const handleSubmit = async (event: React.FormEvent) => {
|
||||||
const finalVariant = variants.find((variant) => variant.id === selectedVariantId)!;
|
event.preventDefault();
|
||||||
|
event.stopPropagation(); // Assure-toi d'arrêter la propagation des événements
|
||||||
|
|
||||||
|
if (selectedVariant) {
|
||||||
|
addCartItem(selectedVariant, product); // Appel avec l'objet ProductVariant
|
||||||
|
|
||||||
|
// Appel côté serveur pour ajouter au panier via Shopify
|
||||||
|
try {
|
||||||
|
await addItem(null, selectedVariant.id); // Appel côté serveur
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding item to server cart:', error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('No variant selected');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form onSubmit={handleSubmit}>
|
||||||
action={async () => {
|
<SubmitButton
|
||||||
addCartItem(finalVariant, product);
|
availableForSale={availableForSale}
|
||||||
await actionWithVariant();
|
selectedVariantId={selectedVariant?.id} // Utilisation de l'ID du variant dans le bouton
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
<SubmitButton availableForSale={availableForSale} selectedVariantId={selectedVariantId} />
|
|
||||||
<p aria-live="polite" className="sr-only" role="status">
|
|
||||||
{message}
|
|
||||||
</p>
|
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { Cart, CartItem, Product, ProductVariant } from 'lib/shopify/types';
|
import type { Cart, CartItem, Product, ProductVariant } from 'lib/shopify/types';
|
||||||
import React, { createContext, use, useContext, useMemo, useOptimistic } from 'react';
|
import React, {
|
||||||
|
createContext,
|
||||||
|
startTransition,
|
||||||
|
use,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useOptimistic
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
type UpdateType = 'plus' | 'minus' | 'delete';
|
type UpdateType = 'plus' | 'minus' | 'delete';
|
||||||
|
|
||||||
@ -50,9 +57,10 @@ function createOrUpdateCartItem(
|
|||||||
): CartItem {
|
): CartItem {
|
||||||
const quantity = existingItem ? existingItem.quantity + 1 : 1;
|
const quantity = existingItem ? existingItem.quantity + 1 : 1;
|
||||||
const totalAmount = calculateItemCost(quantity, variant.price.amount);
|
const totalAmount = calculateItemCost(quantity, variant.price.amount);
|
||||||
|
const itemId = variant.id ?? existingItem?.id;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: existingItem?.id,
|
id: itemId,
|
||||||
quantity,
|
quantity,
|
||||||
cost: {
|
cost: {
|
||||||
totalAmount: {
|
totalAmount: {
|
||||||
@ -131,6 +139,10 @@ function cartReducer(state: Cart | undefined, action: CartAction): Cart {
|
|||||||
}
|
}
|
||||||
case 'ADD_ITEM': {
|
case 'ADD_ITEM': {
|
||||||
const { variant, product } = action.payload;
|
const { variant, product } = action.payload;
|
||||||
|
if (!variant.id) {
|
||||||
|
return currentCart; // Ne pas ajouter l'article si l'ID est manquant
|
||||||
|
}
|
||||||
|
|
||||||
const existingItem = currentCart.lines.find((item) => item.merchandise.id === variant.id);
|
const existingItem = currentCart.lines.find((item) => item.merchandise.id === variant.id);
|
||||||
const updatedItem = createOrUpdateCartItem(existingItem, variant, product);
|
const updatedItem = createOrUpdateCartItem(existingItem, variant, product);
|
||||||
|
|
||||||
@ -156,11 +168,15 @@ export function CartProvider({
|
|||||||
const [optimisticCart, updateOptimisticCart] = useOptimistic(initialCart, cartReducer);
|
const [optimisticCart, updateOptimisticCart] = useOptimistic(initialCart, cartReducer);
|
||||||
|
|
||||||
const updateCartItem = (merchandiseId: string, updateType: UpdateType) => {
|
const updateCartItem = (merchandiseId: string, updateType: UpdateType) => {
|
||||||
|
startTransition(() => {
|
||||||
updateOptimisticCart({ type: 'UPDATE_ITEM', payload: { merchandiseId, updateType } });
|
updateOptimisticCart({ type: 'UPDATE_ITEM', payload: { merchandiseId, updateType } });
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const addCartItem = (variant: ProductVariant, product: Product) => {
|
const addCartItem = (variant: ProductVariant, product: Product) => {
|
||||||
|
startTransition(() => {
|
||||||
updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } });
|
updateOptimisticCart({ type: 'ADD_ITEM', payload: { variant, product } });
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||||
import { removeItem } from 'components/cart/actions';
|
import { removeItem } from 'components/cart/actions';
|
||||||
import type { CartItem } from 'lib/shopify/types';
|
import type { CartItem } from 'lib/shopify/types';
|
||||||
import { useFormState } from 'react-dom';
|
import { useActionState } from 'react';
|
||||||
|
|
||||||
export function DeleteItemButton({
|
export function DeleteItemButton({
|
||||||
item,
|
item,
|
||||||
@ -12,7 +12,7 @@ export function DeleteItemButton({
|
|||||||
item: CartItem;
|
item: CartItem;
|
||||||
optimisticUpdate: any;
|
optimisticUpdate: any;
|
||||||
}) {
|
}) {
|
||||||
const [message, formAction] = useFormState(removeItem, null);
|
const [message, formAction] = useActionState(removeItem, null);
|
||||||
const merchandiseId = item.merchandise.id;
|
const merchandiseId = item.merchandise.id;
|
||||||
const actionWithVariant = formAction.bind(null, merchandiseId);
|
const actionWithVariant = formAction.bind(null, merchandiseId);
|
||||||
|
|
||||||
|
@ -4,7 +4,7 @@ import { MinusIcon, PlusIcon } from '@heroicons/react/24/outline';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { updateItemQuantity } from 'components/cart/actions';
|
import { updateItemQuantity } from 'components/cart/actions';
|
||||||
import type { CartItem } from 'lib/shopify/types';
|
import type { CartItem } from 'lib/shopify/types';
|
||||||
import { useFormState } from 'react-dom';
|
import { useActionState } from 'react';
|
||||||
|
|
||||||
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
|
function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
|
||||||
return (
|
return (
|
||||||
@ -17,6 +17,7 @@ function SubmitButton({ type }: { type: 'plus' | 'minus' }) {
|
|||||||
'ml-auto': type === 'minus'
|
'ml-auto': type === 'minus'
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
|
data-test={`cart-quantity-${type}-button`}
|
||||||
>
|
>
|
||||||
{type === 'plus' ? (
|
{type === 'plus' ? (
|
||||||
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
|
<PlusIcon className="h-4 w-4 dark:text-neutral-500" />
|
||||||
@ -36,7 +37,7 @@ export function EditItemQuantityButton({
|
|||||||
type: 'plus' | 'minus';
|
type: 'plus' | 'minus';
|
||||||
optimisticUpdate: any;
|
optimisticUpdate: any;
|
||||||
}) {
|
}) {
|
||||||
const [message, formAction] = useFormState(updateItemQuantity, null);
|
const [message, formAction] = useActionState(updateItemQuantity, null);
|
||||||
const payload = {
|
const payload = {
|
||||||
merchandiseId: item.merchandise.id,
|
merchandiseId: item.merchandise.id,
|
||||||
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
|
quantity: type === 'plus' ? item.quantity + 1 : item.quantity - 1
|
||||||
|
@ -49,7 +49,7 @@ export default function CartModal() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button aria-label="Open cart" onClick={openCart}>
|
<button aria-label="Open cart" onClick={openCart} data-test="cart-open-button">
|
||||||
<OpenCart quantity={cart?.totalQuantity} />
|
<OpenCart quantity={cart?.totalQuantity} />
|
||||||
</button>
|
</button>
|
||||||
<Transition show={isOpen}>
|
<Transition show={isOpen}>
|
||||||
@ -63,7 +63,11 @@ export default function CartModal() {
|
|||||||
leaveFrom="opacity-100 backdrop-blur-[.5px]"
|
leaveFrom="opacity-100 backdrop-blur-[.5px]"
|
||||||
leaveTo="opacity-0 backdrop-blur-none"
|
leaveTo="opacity-0 backdrop-blur-none"
|
||||||
>
|
>
|
||||||
<div className="fixed inset-0 bg-black/30" aria-hidden="true" />
|
<div
|
||||||
|
className="fixed inset-0 bg-black/30"
|
||||||
|
aria-hidden="true"
|
||||||
|
data-test="cart-modal-container"
|
||||||
|
/>
|
||||||
</Transition.Child>
|
</Transition.Child>
|
||||||
<Transition.Child
|
<Transition.Child
|
||||||
as={Fragment}
|
as={Fragment}
|
||||||
@ -74,10 +78,15 @@ export default function CartModal() {
|
|||||||
leaveFrom="translate-x-0"
|
leaveFrom="translate-x-0"
|
||||||
leaveTo="translate-x-full"
|
leaveTo="translate-x-full"
|
||||||
>
|
>
|
||||||
<Dialog.Panel className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 text-black backdrop-blur-xl md:w-[390px] dark:border-neutral-700 dark:bg-black/80 dark:text-white">
|
<Dialog.Panel
|
||||||
|
className="fixed bottom-0 right-0 top-0 flex h-full w-full flex-col border-l border-neutral-200 bg-white/80 p-6 text-black backdrop-blur-xl md:w-[390px] dark:border-neutral-700 dark:bg-black/80 dark:text-white"
|
||||||
|
data-test="cart-side-container"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-lg font-semibold">My Cart</p>
|
<p className="text-lg font-semibold" data-test="cart-title">
|
||||||
<button aria-label="Close cart" onClick={closeCart}>
|
My Cart
|
||||||
|
</p>
|
||||||
|
<button aria-label="Close cart" onClick={closeCart} data-test="cart-close-button">
|
||||||
<CloseCart />
|
<CloseCart />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -85,7 +94,9 @@ export default function CartModal() {
|
|||||||
{!cart || cart.lines.length === 0 ? (
|
{!cart || cart.lines.length === 0 ? (
|
||||||
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
|
<div className="mt-20 flex w-full flex-col items-center justify-center overflow-hidden">
|
||||||
<ShoppingCartIcon className="h-16" />
|
<ShoppingCartIcon className="h-16" />
|
||||||
<p className="mt-6 text-center text-2xl font-bold">Your cart is empty.</p>
|
<p className="mt-6 text-center text-2xl font-bold" data-test="cart-empty">
|
||||||
|
Your cart is empty.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col justify-between overflow-hidden p-1">
|
<div className="flex h-full flex-col justify-between overflow-hidden p-1">
|
||||||
@ -112,12 +123,13 @@ export default function CartModal() {
|
|||||||
<li
|
<li
|
||||||
key={i}
|
key={i}
|
||||||
className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700"
|
className="flex w-full flex-col border-b border-neutral-300 dark:border-neutral-700"
|
||||||
|
data-test="cart-item"
|
||||||
>
|
>
|
||||||
<div className="relative flex w-full flex-row justify-between px-1 py-4">
|
<div className="relative flex w-full flex-row justify-between px-1 py-4">
|
||||||
<div className="absolute z-40 -ml-1 -mt-2">
|
<div className="absolute z-40 -ml-1 -mt-2">
|
||||||
<DeleteItemButton item={item} optimisticUpdate={updateCartItem} />
|
<DeleteItemButton item={item} optimisticUpdate={updateCartItem} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row" data-test="cart-product-container">
|
||||||
<div className="relative h-16 w-16 overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
|
<div className="relative h-16 w-16 overflow-hidden rounded-md border border-neutral-300 bg-neutral-300 dark:border-neutral-700 dark:bg-neutral-900 dark:hover:bg-neutral-800">
|
||||||
<Image
|
<Image
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
@ -128,6 +140,7 @@ export default function CartModal() {
|
|||||||
item.merchandise.product.title
|
item.merchandise.product.title
|
||||||
}
|
}
|
||||||
src={item.merchandise.product.featuredImage.url}
|
src={item.merchandise.product.featuredImage.url}
|
||||||
|
data-test="cart-product-image"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
@ -160,7 +173,12 @@ export default function CartModal() {
|
|||||||
optimisticUpdate={updateCartItem}
|
optimisticUpdate={updateCartItem}
|
||||||
/>
|
/>
|
||||||
<p className="w-6 text-center">
|
<p className="w-6 text-center">
|
||||||
<span className="w-full text-sm">{item.quantity}</span>
|
<span
|
||||||
|
className="w-full text-sm"
|
||||||
|
data-test="cart-product-quantity"
|
||||||
|
>
|
||||||
|
{item.quantity}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<EditItemQuantityButton
|
<EditItemQuantityButton
|
||||||
item={item}
|
item={item}
|
||||||
@ -174,7 +192,10 @@ export default function CartModal() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="py-4 text-sm text-neutral-500 dark:text-neutral-400">
|
<div
|
||||||
|
className="py-4 text-sm text-neutral-500 dark:text-neutral-400"
|
||||||
|
data-test="payment-information"
|
||||||
|
>
|
||||||
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
|
<div className="mb-3 flex items-center justify-between border-b border-neutral-200 pb-1 dark:border-neutral-700">
|
||||||
<p>Taxes</p>
|
<p>Taxes</p>
|
||||||
<Price
|
<Price
|
||||||
@ -217,6 +238,7 @@ function CheckoutButton() {
|
|||||||
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
|
className="block w-full rounded-full bg-blue-600 p-3 text-center text-sm font-medium text-white opacity-90 hover:opacity-100"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
|
data-test="checkout-button"
|
||||||
>
|
>
|
||||||
{pending ? <LoadingDots className="bg-white" /> : 'Proceed to Checkout'}
|
{pending ? <LoadingDots className="bg-white" /> : 'Proceed to Checkout'}
|
||||||
</button>
|
</button>
|
||||||
|
@ -6,20 +6,24 @@ import Link from 'next/link';
|
|||||||
function ThreeItemGridItem({
|
function ThreeItemGridItem({
|
||||||
item,
|
item,
|
||||||
size,
|
size,
|
||||||
priority
|
priority,
|
||||||
|
index
|
||||||
}: {
|
}: {
|
||||||
item: Product;
|
item: Product;
|
||||||
size: 'full' | 'half';
|
size: 'full' | 'half';
|
||||||
priority?: boolean;
|
priority?: boolean;
|
||||||
|
index: number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'}
|
className={size === 'full' ? 'md:col-span-4 md:row-span-2' : 'md:col-span-2 md:row-span-1'}
|
||||||
|
data-test={index !== undefined ? `grid-item-${size}-${index}` : `grid-item-${size}`}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
className="relative block aspect-square h-full w-full"
|
className="relative block aspect-square h-full w-full"
|
||||||
href={`/product/${item.handle}`}
|
href={`/product/${item.handle}`}
|
||||||
prefetch={true}
|
prefetch={true}
|
||||||
|
data-test="product-link"
|
||||||
>
|
>
|
||||||
<GridTileImage
|
<GridTileImage
|
||||||
src={item.featuredImage.url}
|
src={item.featuredImage.url}
|
||||||
@ -52,10 +56,13 @@ export async function ThreeItemGrid() {
|
|||||||
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
|
const [firstProduct, secondProduct, thirdProduct] = homepageItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2 lg:max-h-[calc(100vh-200px)]">
|
<section
|
||||||
<ThreeItemGridItem size="full" item={firstProduct} priority={true} />
|
className="mx-auto grid max-w-screen-2xl gap-4 px-4 pb-4 md:grid-cols-6 md:grid-rows-2 lg:max-h-[calc(100vh-200px)]"
|
||||||
<ThreeItemGridItem size="half" item={secondProduct} priority={true} />
|
data-test="three-item-grid"
|
||||||
<ThreeItemGridItem size="half" item={thirdProduct} />
|
>
|
||||||
|
<ThreeItemGridItem size="full" item={firstProduct} index={0} priority={true} />
|
||||||
|
<ThreeItemGridItem size="half" item={secondProduct} index={1} priority={true} />
|
||||||
|
<ThreeItemGridItem size="half" item={thirdProduct} index={2} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -27,6 +27,7 @@ export function GridTileImage({
|
|||||||
'border-neutral-200 dark:border-neutral-800': !active
|
'border-neutral-200 dark:border-neutral-800': !active
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
|
data-test="tile-container"
|
||||||
>
|
>
|
||||||
{props.src ? (
|
{props.src ? (
|
||||||
<Image
|
<Image
|
||||||
@ -34,6 +35,7 @@ export function GridTileImage({
|
|||||||
'transition duration-300 ease-in-out group-hover:scale-105': isInteractive
|
'transition duration-300 ease-in-out group-hover:scale-105': isInteractive
|
||||||
})}
|
})}
|
||||||
{...props}
|
{...props}
|
||||||
|
data-test="tile-image"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{label ? (
|
{label ? (
|
||||||
|
@ -17,9 +17,18 @@ const Label = ({
|
|||||||
className={clsx('absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label', {
|
className={clsx('absolute bottom-0 left-0 flex w-full px-4 pb-4 @container/label', {
|
||||||
'lg:px-20 lg:pb-[35%]': position === 'center'
|
'lg:px-20 lg:pb-[35%]': position === 'center'
|
||||||
})}
|
})}
|
||||||
|
data-test="label-container"
|
||||||
>
|
>
|
||||||
<div className="flex items-center rounded-full border bg-white/70 p-1 text-xs font-semibold text-black backdrop-blur-md dark:border-neutral-800 dark:bg-black/70 dark:text-white">
|
<div
|
||||||
<h3 className="mr-4 line-clamp-2 flex-grow pl-2 leading-none tracking-tight">{title}</h3>
|
className="flex items-center rounded-full border bg-white/70 p-1 text-xs font-semibold text-black backdrop-blur-md dark:border-neutral-800 dark:bg-black/70 dark:text-white"
|
||||||
|
data-test="label-content-wrapper"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
className="mr-4 line-clamp-2 flex-grow pl-2 leading-none tracking-tight"
|
||||||
|
data-test="label-title-text"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
<Price
|
<Price
|
||||||
className="flex-none rounded-full bg-blue-600 p-2 text-white"
|
className="flex-none rounded-full bg-blue-600 p-2 text-white"
|
||||||
amount={amount}
|
amount={amount}
|
||||||
|
@ -15,7 +15,7 @@ export default async function Footer() {
|
|||||||
const copyrightName = COMPANY_NAME || SITE_NAME || '';
|
const copyrightName = COMPANY_NAME || SITE_NAME || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="text-sm text-neutral-500 dark:text-neutral-400">
|
<footer className="text-sm text-neutral-500 dark:text-neutral-400" data-test="footer">
|
||||||
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm md:flex-row md:gap-12 md:px-4 min-[1320px]:px-0 dark:border-neutral-700">
|
<div className="mx-auto flex w-full max-w-7xl flex-col gap-6 border-t border-neutral-200 px-6 py-12 text-sm md:flex-row md:gap-12 md:px-4 min-[1320px]:px-0 dark:border-neutral-700">
|
||||||
<div>
|
<div>
|
||||||
<Link className="flex items-center gap-2 text-black md:pt-1 dark:text-white" href="/">
|
<Link className="flex items-center gap-2 text-black md:pt-1 dark:text-white" href="/">
|
||||||
|
@ -13,7 +13,7 @@ export async function Navbar() {
|
|||||||
const menu = await getMenu('next-js-frontend-header-menu');
|
const menu = await getMenu('next-js-frontend-header-menu');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="relative flex items-center justify-between p-4 lg:px-6">
|
<nav className="relative flex items-center justify-between p-4 lg:px-6" data-test="navbar">
|
||||||
<div className="block flex-none md:hidden">
|
<div className="block flex-none md:hidden">
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<MobileMenu menu={menu} />
|
<MobileMenu menu={menu} />
|
||||||
@ -25,6 +25,7 @@ export async function Navbar() {
|
|||||||
href="/"
|
href="/"
|
||||||
prefetch={true}
|
prefetch={true}
|
||||||
className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6"
|
className="mr-2 flex w-full items-center justify-center md:w-auto lg:mr-6"
|
||||||
|
data-test="navbar-logo"
|
||||||
>
|
>
|
||||||
<LogoSquare />
|
<LogoSquare />
|
||||||
<div className="ml-2 flex-none text-sm font-medium uppercase md:hidden lg:block">
|
<div className="ml-2 flex-none text-sm font-medium uppercase md:hidden lg:block">
|
||||||
|
@ -36,6 +36,7 @@ export default function MobileMenu({ menu }: { menu: Menu[] }) {
|
|||||||
onClick={openMobileMenu}
|
onClick={openMobileMenu}
|
||||||
aria-label="Open mobile menu"
|
aria-label="Open mobile menu"
|
||||||
className="flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors md:hidden dark:border-neutral-700 dark:text-white"
|
className="flex h-11 w-11 items-center justify-center rounded-md border border-neutral-200 text-black transition-colors md:hidden dark:border-neutral-700 dark:text-white"
|
||||||
|
data-test="mobile-menu-button"
|
||||||
>
|
>
|
||||||
<Bars3Icon className="h-4" />
|
<Bars3Icon className="h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
@ -17,6 +17,7 @@ export default function Search() {
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
defaultValue={searchParams?.get('q') || ''}
|
defaultValue={searchParams?.get('q') || ''}
|
||||||
className="text-md w-full rounded-lg border bg-white px-4 py-2 text-black placeholder:text-neutral-500 md:text-sm dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
|
className="text-md w-full rounded-lg border bg-white px-4 py-2 text-black placeholder:text-neutral-500 md:text-sm dark:border-neutral-800 dark:bg-transparent dark:text-white dark:placeholder:text-neutral-400"
|
||||||
|
data-test="search-input"
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
|
<div className="absolute right-0 top-0 mr-3 flex h-full items-center">
|
||||||
<MagnifyingGlassIcon className="h-4" />
|
<MagnifyingGlassIcon className="h-4" />
|
||||||
|
@ -26,6 +26,7 @@ function PathFilterItem({ item }: { item: PathFilterItem }) {
|
|||||||
'underline underline-offset-4': active
|
'underline underline-offset-4': active
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
|
data-test="search-filter-item"
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</DynamicTag>
|
</DynamicTag>
|
||||||
|
@ -3,7 +3,7 @@ import clsx from 'clsx';
|
|||||||
const Price = ({
|
const Price = ({
|
||||||
amount,
|
amount,
|
||||||
className,
|
className,
|
||||||
currencyCode = 'USD',
|
currencyCode = 'EUR',
|
||||||
currencyCodeClassName
|
currencyCodeClassName
|
||||||
}: {
|
}: {
|
||||||
amount: string;
|
amount: string;
|
||||||
@ -11,13 +11,16 @@ const Price = ({
|
|||||||
currencyCode: string;
|
currencyCode: string;
|
||||||
currencyCodeClassName?: string;
|
currencyCodeClassName?: string;
|
||||||
} & React.ComponentProps<'p'>) => (
|
} & React.ComponentProps<'p'>) => (
|
||||||
<p suppressHydrationWarning={true} className={className}>
|
<p suppressHydrationWarning={true} className={className} data-test="price-amount">
|
||||||
{`${new Intl.NumberFormat(undefined, {
|
{`${new Intl.NumberFormat(undefined, {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: currencyCode,
|
currency: currencyCode,
|
||||||
currencyDisplay: 'narrowSymbol'
|
currencyDisplay: 'narrowSymbol'
|
||||||
}).format(parseFloat(amount))}`}
|
}).format(parseFloat(amount))}`}
|
||||||
<span className={clsx('ml-1 inline', currencyCodeClassName)}>{`${currencyCode}`}</span>
|
<span
|
||||||
|
className={clsx('ml-1 inline', currencyCodeClassName)}
|
||||||
|
data-test="price-currency-code"
|
||||||
|
>{`${currencyCode}`}</span>
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -8,8 +8,13 @@ export function ProductDescription({ product }: { product: Product }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700">
|
<div className="mb-6 flex flex-col border-b pb-6 dark:border-neutral-700">
|
||||||
<h1 className="mb-2 text-5xl font-medium">{product.title}</h1>
|
<h1 className="mb-2 text-5xl font-medium" data-test="product-title">
|
||||||
<div className="mr-auto w-auto rounded-full bg-blue-600 p-2 text-sm text-white">
|
{product.title}
|
||||||
|
</h1>
|
||||||
|
<div
|
||||||
|
className="mr-auto w-auto rounded-full bg-blue-600 p-2 text-sm text-white"
|
||||||
|
data-test="price-container"
|
||||||
|
>
|
||||||
<Price
|
<Price
|
||||||
amount={product.priceRange.maxVariantPrice.amount}
|
amount={product.priceRange.maxVariantPrice.amount}
|
||||||
currencyCode={product.priceRange.maxVariantPrice.currencyCode}
|
currencyCode={product.priceRange.maxVariantPrice.currencyCode}
|
||||||
|
@ -81,6 +81,7 @@ export function VariantSelector({
|
|||||||
!isAvailableForSale
|
!isAvailableForSale
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
|
data-test={`${option.name}-${value}`}
|
||||||
>
|
>
|
||||||
{value}
|
{value}
|
||||||
</button>
|
</button>
|
||||||
|
@ -1,10 +1,28 @@
|
|||||||
import { defineConfig } from 'cypress';
|
import { defineConfig } from 'cypress';
|
||||||
|
import createBundler from '@bahmutov/cypress-esbuild-preprocessor';
|
||||||
|
import { addCucumberPreprocessorPlugin } from '@badeball/cypress-cucumber-preprocessor';
|
||||||
|
import createEsbuildPlugin from '@badeball/cypress-cucumber-preprocessor/esbuild';
|
||||||
|
|
||||||
|
async function setupNodeEvents(
|
||||||
|
on: Cypress.PluginEvents,
|
||||||
|
config: Cypress.PluginConfigOptions
|
||||||
|
): Promise<Cypress.PluginConfigOptions> {
|
||||||
|
await addCucumberPreprocessorPlugin(on, config);
|
||||||
|
|
||||||
|
on(
|
||||||
|
'file:preprocessor',
|
||||||
|
createBundler({
|
||||||
|
plugins: [createEsbuildPlugin(config)]
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
e2e: {
|
e2e: {
|
||||||
baseUrl: 'http://localhost:3000',
|
baseUrl: 'http://localhost:3000',
|
||||||
setupNodeEvents(on, config) {
|
specPattern: '**/*.feature',
|
||||||
// implement node event listeners here
|
setupNodeEvents
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
23
cypress/e2e/common-step-definitions/common.ts
Normal file
23
cypress/e2e/common-step-definitions/common.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Given } from '@badeball/cypress-cucumber-preprocessor';
|
||||||
|
import { toSlug, verifyRequest } from 'cypress/utils/url';
|
||||||
|
|
||||||
|
Given('I am on the product detail page for {string}', (productName: string) => {
|
||||||
|
const slugifiedProductName = toSlug(productName);
|
||||||
|
cy.intercept('GET', `/product/${slugifiedProductName}`).as('getProduct');
|
||||||
|
cy.intercept('POST', '/product/*').as('postProduct');
|
||||||
|
|
||||||
|
// Visite de la page produit
|
||||||
|
cy.visit(`/product/${slugifiedProductName}`);
|
||||||
|
|
||||||
|
// Femrer la notification de NextJs
|
||||||
|
cy.get('body > main > section')
|
||||||
|
.find('button')
|
||||||
|
.should('exist')
|
||||||
|
.and('be.visible')
|
||||||
|
.and('not.be.disabled')
|
||||||
|
.trigger('click');
|
||||||
|
|
||||||
|
// Vérifie la réponse du produit
|
||||||
|
verifyRequest('@getProduct');
|
||||||
|
verifyRequest('@postProduct');
|
||||||
|
});
|
64
cypress/e2e/features/cart/add-to-cart.feature
Normal file
64
cypress/e2e/features/cart/add-to-cart.feature
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
Feature: Add product to cart
|
||||||
|
As a customer
|
||||||
|
I want to add products to my shopping cart
|
||||||
|
So that I can purchase them later
|
||||||
|
|
||||||
|
Scenario: Add a product to the cart from the product page
|
||||||
|
Given I am on the product detail page for "<product-name>"
|
||||||
|
When I select "<color>" color of "<product-name>"
|
||||||
|
And I click "Add to Cart"
|
||||||
|
Then the product should be added to my shopping cart
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| color | product-name |
|
||||||
|
| Ice |The Complete Snowboard|
|
||||||
|
| Dawn |The Complete Snowboard|
|
||||||
|
| Powder |The Complete Snowboard|
|
||||||
|
| Electric |The Complete Snowboard|
|
||||||
|
| Sunset |The Complete Snowboard|
|
||||||
|
|
||||||
|
|
||||||
|
Scenario: Check that the cart total updates after adding a product
|
||||||
|
Given I am on the product detail page for "<product-name>"
|
||||||
|
When I click "Add to Cart"
|
||||||
|
And the "<product-name>" costs "<amount>" "<code-currency>"
|
||||||
|
Then the total in the shopping cart should be updated to "<amount>" "<code-currency>"
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| product-name | amount | code-currency |
|
||||||
|
| The Compare at Price Snowboard |785,95 | EUR |
|
||||||
|
|
||||||
|
Scenario: Cannot add a product to the cart without selecting a required option
|
||||||
|
Given I am on the product detail page for "<product-name>"
|
||||||
|
When I do not select a color
|
||||||
|
Then I Can not add the product in the shopping cart
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| product-name |
|
||||||
|
|The Complete Snowboard|
|
||||||
|
|
||||||
|
Scenario: Add a product to the cart that is out of stock
|
||||||
|
Given I am on the product detail page for "<product-name>"
|
||||||
|
And the "<product-name>" is out of stock
|
||||||
|
When I click "Add to Cart"
|
||||||
|
Then the product should not be added to my shopping cart
|
||||||
|
And "Add to Cart" button should have the name "Out Of Stock"
|
||||||
|
Examples:
|
||||||
|
| product-name |
|
||||||
|
|The Out of Stock Snowboard|
|
||||||
|
|
||||||
|
Scenario: Add multiple quantities of a product to the cart
|
||||||
|
Given I am on the product detail page for "<product-name>"
|
||||||
|
When I select "<color>" color of "<product-name>"
|
||||||
|
And I click "Add to Cart"
|
||||||
|
And I select "<quantity>" quantities
|
||||||
|
Then the cart should contain "<quantity>" items of "<product-name>"
|
||||||
|
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
| color | product-name |quantity |
|
||||||
|
| Ice |The Complete Snowboard| 2 |
|
||||||
|
| Dawn |The Complete Snowboard| 2 |
|
||||||
|
| Powder |The Complete Snowboard| 2 |
|
||||||
|
| Electric |The Complete Snowboard| 2 |
|
||||||
|
| Sunset |The Complete Snowboard| 2 |
|
209
cypress/e2e/features/cart/add-to-cart.ts
Normal file
209
cypress/e2e/features/cart/add-to-cart.ts
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { Given, When, Then } from '@badeball/cypress-cucumber-preprocessor';
|
||||||
|
import { toSlug, verifyRequest } from 'cypress/utils/url';
|
||||||
|
|
||||||
|
Given(
|
||||||
|
'the {string} is out of stock',
|
||||||
|
|
||||||
|
(productName: string) => {
|
||||||
|
// Charger la fixture
|
||||||
|
cy.fixture('product-query.json').then((query) => {
|
||||||
|
// Insérer le nom du produit dans la requête
|
||||||
|
query.variables.productName = productName; // Nom du produit à interroger
|
||||||
|
|
||||||
|
// Faire la requête GraphQL
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: 'testify-automation.myshopify.com/api/2024-07/graphql', // URL complète de l'API GraphQL de Shopify
|
||||||
|
body: query,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Shopify-Storefront-Access-Token': '8bc822f670ae4977b83b0975fa80054b' // Token d'accès
|
||||||
|
},
|
||||||
|
failOnStatusCode: false // Empêche Cypress d'échouer automatiquement
|
||||||
|
}).then((response) => {
|
||||||
|
// Afficher la réponse dans la console pour le débogage
|
||||||
|
console.log('response : ', response);
|
||||||
|
|
||||||
|
// Vérifier le statut de la réponse
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
|
||||||
|
// Vérifier si le produit existe dans la réponse
|
||||||
|
const product = response.body.data?.products?.edges[0]?.node;
|
||||||
|
expect(product).to.exist;
|
||||||
|
|
||||||
|
// Assertion pour le nom du produit
|
||||||
|
expect(product).to.have.property('title');
|
||||||
|
expect(product.title).to.equal(productName);
|
||||||
|
// Assertion du stock du produit
|
||||||
|
expect(product).to.have.property('totalInventory');
|
||||||
|
expect(product.totalInventory).to.equal(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
When('I select {string} color of {string}', (expectedColor: string, productName: string) => {
|
||||||
|
const slugifiedProductName = toSlug(productName);
|
||||||
|
// Intercepter les requêtes pour sélectionner une variante et changer la couleur
|
||||||
|
cy.intercept('GET', `/product\/${slugifiedProductName}\?color=*`).as('getColorVariant');
|
||||||
|
// cy.intercept('POST', `/product\/${slugifiedProductName}`).as('selectColorVariant');
|
||||||
|
|
||||||
|
// Vérifier l'état du bouton "Add to Cart" avant la sélection de la couleur
|
||||||
|
cy.getBySel('add-to-cart').should('exist').and('be.visible').and('be.disabled');
|
||||||
|
|
||||||
|
// Vérifie la couleur attendue et sélectionne-la
|
||||||
|
cy.getBySel(`Color-${expectedColor}`)
|
||||||
|
.should('exist')
|
||||||
|
.and('be.visible')
|
||||||
|
.and('be.enabled')
|
||||||
|
.invoke('text')
|
||||||
|
.should('equal', `${expectedColor}`);
|
||||||
|
|
||||||
|
cy.getBySel(`Color-${expectedColor}`).click();
|
||||||
|
|
||||||
|
// Vérifie les requêtes après sélection de la couleur
|
||||||
|
// verifyRequest('@postProduct');
|
||||||
|
verifyRequest('@getColorVariant');
|
||||||
|
// verifyRequest('@selectColorVariant');
|
||||||
|
});
|
||||||
|
|
||||||
|
When(
|
||||||
|
'the {string} costs {string} {string}',
|
||||||
|
(productName: string, amount: string, currencyCode: string) => {
|
||||||
|
// Charger la fixture
|
||||||
|
cy.fixture('product-query.json').then((query) => {
|
||||||
|
// Insérer le nom du produit dans la requête
|
||||||
|
query.variables.productName = productName; // Nom du produit à interroger
|
||||||
|
|
||||||
|
// Faire la requête GraphQL
|
||||||
|
cy.request({
|
||||||
|
method: 'POST',
|
||||||
|
url: 'testify-automation.myshopify.com/api/2024-07/graphql', // URL complète de l'API GraphQL de Shopify
|
||||||
|
body: query,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Shopify-Storefront-Access-Token': '8bc822f670ae4977b83b0975fa80054b' // Token d'accès
|
||||||
|
},
|
||||||
|
failOnStatusCode: false // Empêche Cypress d'échouer automatiquement
|
||||||
|
}).then((response) => {
|
||||||
|
// Afficher la réponse dans la console pour le débogage
|
||||||
|
console.log('response : ', response);
|
||||||
|
|
||||||
|
// Vérifier le statut de la réponse
|
||||||
|
expect(response.status).to.eq(200);
|
||||||
|
|
||||||
|
// Vérifier si le produit existe dans la réponse
|
||||||
|
const product = response.body.data?.products?.edges[0]?.node;
|
||||||
|
expect(product).to.exist;
|
||||||
|
|
||||||
|
// Assertion pour le nom du produit
|
||||||
|
expect(product).to.have.property('title');
|
||||||
|
expect(product.title).to.equal(productName);
|
||||||
|
|
||||||
|
// Vérifier si les variantes existent
|
||||||
|
const variant = product.variants?.edges[0]?.node;
|
||||||
|
expect(variant).to.exist;
|
||||||
|
|
||||||
|
// Vérifier les informations sur le prix
|
||||||
|
const price = variant.price;
|
||||||
|
expect(price).to.have.property('amount');
|
||||||
|
expect(price.amount).to.equal(amount.replace(',', '.'));
|
||||||
|
expect(price).to.have.property('currencyCode');
|
||||||
|
expect(price.currencyCode).to.equal(currencyCode);
|
||||||
|
|
||||||
|
// Logs supplémentaires pour vérifier les variantes et les prix
|
||||||
|
product.variants.edges.forEach((variant: any) => {
|
||||||
|
cy.log(`Variant: ${variant.node.title}`);
|
||||||
|
cy.log(`Price: ${variant.node.price.amount} ${variant.node.price.currencyCode}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
When('I click {string}', (buttonName: string) => {
|
||||||
|
// cy.intercept('POST', '/product/*').as('createNewCart');
|
||||||
|
const slugifiedButtonName = toSlug(buttonName);
|
||||||
|
// Vérifie l'existence et l'état du bouton
|
||||||
|
cy.getBySel(slugifiedButtonName).should('exist').and('be.visible');
|
||||||
|
|
||||||
|
// Vérifie que le cookie `cartId` est bien présent et défini
|
||||||
|
|
||||||
|
// cy.wait('@postProduct');
|
||||||
|
// cy.wait('@createNewCart');
|
||||||
|
cy.getCookie('cartId').should('exist').its('value').should('not.be.undefined');
|
||||||
|
|
||||||
|
// Clique sur le bouton
|
||||||
|
cy.getBySel(slugifiedButtonName).click({ force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I do not select a color', () => {
|
||||||
|
cy.get('[data-test^="Color-"]').should('exist').and('be.visible');
|
||||||
|
});
|
||||||
|
|
||||||
|
When('I select {string} quantities', (quantity: string) => {
|
||||||
|
cy.getBySel('cart-quantity-plus-button').click();
|
||||||
|
Array.from({ length: parseInt(quantity) }).forEach(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
Then(
|
||||||
|
'the cart should contain {string} items of {string}',
|
||||||
|
(quantity: string, productName: string) => {
|
||||||
|
cy.getBySel('cart-product-container')
|
||||||
|
.invoke('text')
|
||||||
|
.then((productProperty) => {
|
||||||
|
expect(productProperty).contains(productName);
|
||||||
|
});
|
||||||
|
// Vérifier que la quantité affichée correspond à la quantité sélectionnée
|
||||||
|
cy.getBySel('cart-product-quantity').should('have.text', quantity);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Then('the product should be added to my shopping cart', () => {
|
||||||
|
// Vérifie que le panier contient bien le produit
|
||||||
|
cy.getBySel('cart-open-button').invoke('text').should('equal', '1');
|
||||||
|
cy.getBySel('cart-item').should('have.length', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
Then(
|
||||||
|
'the total in the shopping cart should be updated to {string} {string}',
|
||||||
|
(amount: string, codeCurrency: string) => {
|
||||||
|
cy.get('[data-test="cart-side-container"] [data-test="price-amount"]')
|
||||||
|
.should('exist')
|
||||||
|
.and('be.visible')
|
||||||
|
.invoke('text')
|
||||||
|
.then((text) => {
|
||||||
|
// Vérifier que le texte contient la devise
|
||||||
|
expect(text).to.include(codeCurrency);
|
||||||
|
});
|
||||||
|
cy.getAmount(
|
||||||
|
'[data-test="payment-information"] > :nth-child(3) > [data-test="price-amount"]',
|
||||||
|
amount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Then('the product should not be added to my shopping cart', () => {
|
||||||
|
cy.getBySel('cart-open-button').click();
|
||||||
|
cy.getBySel('cart-item').should('not.exist');
|
||||||
|
cy.getBySel('cart-empty')
|
||||||
|
.should('exist')
|
||||||
|
.and('be.visible')
|
||||||
|
.invoke('text')
|
||||||
|
.then((label) => {
|
||||||
|
expect(label).to.be.equal('Your cart is empty.');
|
||||||
|
});
|
||||||
|
cy.getBySel('cart-close-button').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
Then('I Can not add the product in the shopping cart', () => {
|
||||||
|
cy.getBySel('add-to-cart').should('exist').and('be.visible').and('be.disabled');
|
||||||
|
});
|
||||||
|
Then('"Add to Cart" button should have the name "Out Of Stock"', () => {
|
||||||
|
cy.getBySel('add-to-cart')
|
||||||
|
.should('exist')
|
||||||
|
.invoke('text')
|
||||||
|
.then((label) => {
|
||||||
|
expect(label).to.equals('Out Of Stock');
|
||||||
|
});
|
||||||
|
});
|
0
cypress/e2e/features/cart/view-cart.feature
Normal file
0
cypress/e2e/features/cart/view-cart.feature
Normal file
0
cypress/e2e/features/checkout/checkout.feature
Normal file
0
cypress/e2e/features/checkout/checkout.feature
Normal file
0
cypress/e2e/features/checkout/checkout.ts
Normal file
0
cypress/e2e/features/checkout/checkout.ts
Normal file
13
cypress/e2e/features/product/product-display.feature
Normal file
13
cypress/e2e/features/product/product-display.feature
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
Feature: Display products on homepage
|
||||||
|
As a customer
|
||||||
|
I want to see the list of available products with their names and prices
|
||||||
|
So that I can decide what to buy
|
||||||
|
|
||||||
|
Scenario: Display list of products on the homepage
|
||||||
|
Given I am on the homepage
|
||||||
|
When the page loads
|
||||||
|
Then I should see a list of products
|
||||||
|
And each product should have an image
|
||||||
|
And each product should have a name
|
||||||
|
And each product should have a price
|
||||||
|
And the price should be displayed in EUR with two decimal points
|
41
cypress/e2e/features/product/product-display.ts
Normal file
41
cypress/e2e/features/product/product-display.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { When, Then } from '@badeball/cypress-cucumber-preprocessor';
|
||||||
|
|
||||||
|
// Étape pour attendre le chargement de la page
|
||||||
|
When('the page loads', () => {
|
||||||
|
cy.get('main').should('exist').and('be.visible'); // Attend 2 secondes pour que la page soit complètement chargée
|
||||||
|
});
|
||||||
|
|
||||||
|
// Étape pour vérifier que la liste des produits est visible
|
||||||
|
Then('I should see a list of products', () => {
|
||||||
|
cy.getBySel('product-link').should('exist').and('be.visible'); // Vérifie que la liste des produits est visible
|
||||||
|
});
|
||||||
|
|
||||||
|
// Étape pour vérifier que chaque produit a une image
|
||||||
|
Then('each product should have an image', () => {
|
||||||
|
cy.getBySel('tile-image').each(($el) => {
|
||||||
|
cy.wrap($el).should('have.attr', 'src').and('not.be.empty'); // Vérifie que chaque produit a une image
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Étape pour vérifier que chaque produit a un nom
|
||||||
|
Then('each product should have a name', () => {
|
||||||
|
cy.getBySel('label-title-text').each(($el) => {
|
||||||
|
cy.wrap($el).should('not.be.empty'); // Vérifie que chaque produit a un nom
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Étape pour vérifier que chaque produit a un prix
|
||||||
|
Then('each product should have a price', () => {
|
||||||
|
cy.getBySel('price-amount').each(($el) => {
|
||||||
|
cy.wrap($el).should('not.be.empty'); // Vérifie que chaque produit a un prix
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Étape pour vérifier que le prix est au format EUR avec deux décimales
|
||||||
|
Then('the price should be displayed in EUR with two decimal points', () => {
|
||||||
|
cy.getBySel('price-amount').each(($el) => {
|
||||||
|
cy.wrap($el)
|
||||||
|
.invoke('text')
|
||||||
|
.should('match', /^[€$£]?(\d{1,3}(,\d{3})*|\d+)\.\d{2}[A-Z]{3}$/); // Vérifie le format EUR avec deux décimales
|
||||||
|
});
|
||||||
|
});
|
0
cypress/e2e/features/product/search-product.feature
Normal file
0
cypress/e2e/features/product/search-product.feature
Normal file
0
cypress/e2e/features/user/user-login.feature
Normal file
0
cypress/e2e/features/user/user-login.feature
Normal file
7
cypress/e2e/features/user/user-login.ts
Normal file
7
cypress/e2e/features/user/user-login.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { Given } from '@badeball/cypress-cucumber-preprocessor';
|
||||||
|
|
||||||
|
// Étape pour aller à la page d'accueil
|
||||||
|
|
||||||
|
Given('I am on the homepage', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
});
|
182
cypress/e2e/header.cy.ts
Normal file
182
cypress/e2e/header.cy.ts
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
import { DATA_TEST_SELECTORS } from 'cypress/utils/constants';
|
||||||
|
|
||||||
|
describe('Header Tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.visit('/');
|
||||||
|
// Femrer la notification de NextJs
|
||||||
|
cy.get('body > main > section')
|
||||||
|
.find('button')
|
||||||
|
.should('exist')
|
||||||
|
.and('be.visible')
|
||||||
|
.and('not.be.disabled')
|
||||||
|
.trigger('click');
|
||||||
|
});
|
||||||
|
it('should display the correct grid items', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
cy.getBySel('navbar').should('be.visible');
|
||||||
|
cy.getBySel('navbar-logo').should('be.visible');
|
||||||
|
cy.getBySel('navbar-logo').should('have.attr', 'href', '/');
|
||||||
|
cy.getBySel('navbar-logo').click();
|
||||||
|
cy.location('pathname').should('eq', '/');
|
||||||
|
|
||||||
|
cy.getBySel('search-input').click().type('{enter}');
|
||||||
|
cy.location('pathname').should('eq', '/search');
|
||||||
|
|
||||||
|
cy.getBySel('search-filter-item').each((item: JQuery<HTMLElement>) => {
|
||||||
|
const text = item.text();
|
||||||
|
if (text === 'Hydrogen') {
|
||||||
|
cy.wrap(item).click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cy.location('pathname').should('eq', '/search/hydrogen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('users can add products to the cart with quantity eq 1 and another product with quantity eq 2', () => {
|
||||||
|
/*
|
||||||
|
Aller vers le premier produit que nous allons ajouter au panier
|
||||||
|
Vérifier que nous atterisson sur le bon chemin du produit
|
||||||
|
Vérifier qu'on récupére bien les élements suivants :
|
||||||
|
- Image
|
||||||
|
- Titre
|
||||||
|
- Description
|
||||||
|
*/
|
||||||
|
|
||||||
|
cy.get(DATA_TEST_SELECTORS.productCarouselSection.items(2).product.link).click();
|
||||||
|
cy.location('pathname').should('eq', '/product/the-inventory-not-tracked-snowboard');
|
||||||
|
cy.getBySel('product-page').should('exist');
|
||||||
|
cy.getBySel('product-details').should('exist');
|
||||||
|
cy.getBySel('product-details').within(() => {
|
||||||
|
cy.getBySel('product-image').should('exist');
|
||||||
|
});
|
||||||
|
cy.getBySel('product-description').should('exist');
|
||||||
|
cy.getBySel('product-title')
|
||||||
|
.invoke('text')
|
||||||
|
.then((title: string) => {
|
||||||
|
expect(title).to.equal('The Inventory Not Tracked Snowboard');
|
||||||
|
});
|
||||||
|
/*
|
||||||
|
Préparer l'interception de l'appel au serveur afin d'être sûr que l'action est fini avant denclencher une nouvelle
|
||||||
|
- Nous allons jouer le scénario suivant :
|
||||||
|
1. Nous allons prendre un seul premier produit et le mettre au panier et vérifier qu'il a :
|
||||||
|
- Une image
|
||||||
|
- Un nom produit
|
||||||
|
- Un prix avec devise
|
||||||
|
- Quantité attendu
|
||||||
|
2. Nous allons prendre un deuxième produit en double et vérifier les mêmes informations que le premier produit.
|
||||||
|
*/
|
||||||
|
cy.intercept('POST', '/product/the-inventory-not-tracked-snowboard').as('addItemToCart'); // Associer une requête réseau
|
||||||
|
cy.getBySel('submit-button').should('exist').invoke('text').should('equal', 'Add To Cart');
|
||||||
|
cy.wait(500);
|
||||||
|
cy.getBySel('submit-button').click(); // J'actionne l'ajout au panier'
|
||||||
|
cy.wait('@addItemToCart'); // Attendre que la requête réseau soit terminée
|
||||||
|
cy.get('[id^="headlessui-dialog-panel"]').should('exist').and('be.visible'); // Vérifier que la model est présente
|
||||||
|
|
||||||
|
// Vérification du produit
|
||||||
|
cy.getBySel('cart-item').should('exist').and('have.length', 1); // Vérifier qu'on un seul produit dans le panier'
|
||||||
|
cy.getBySel('cart-product-quantity').eq(0).invoke('text').should('equal', '1'); // Vérifier qu'on a pris un seul (quantité) produit'
|
||||||
|
|
||||||
|
/* Vérification de l'image qu'elle
|
||||||
|
- Existe
|
||||||
|
- Visible
|
||||||
|
- est chargée
|
||||||
|
- Défini pour l'accessibilité
|
||||||
|
|
||||||
|
*/
|
||||||
|
cy.getBySel('cart-product-image')
|
||||||
|
.should('exist') // Vérifie que l'élément existe
|
||||||
|
.and('be.visible') // Vérifie que l'élément est visible
|
||||||
|
.then(($img) => {
|
||||||
|
// Vérifier que l'élément est bien une image
|
||||||
|
const img = $img[0] as HTMLImageElement;
|
||||||
|
|
||||||
|
// Utiliser 'expect' pour vérifier les propriétés de l'image
|
||||||
|
expect(img).to.have.property('naturalWidth').that.is.greaterThan(0); // Vérifier la largeur naturelle
|
||||||
|
expect(img).to.have.property('naturalHeight').that.is.greaterThan(0); // Vérifier la hauteur naturelle
|
||||||
|
expect(img).to.have.property('width').that.is.greaterThan(0); // Vérifier la largeur naturelle
|
||||||
|
expect(img).to.have.property('height').that.is.greaterThan(0); // Vérifier la hauteur naturelle
|
||||||
|
expect(img.src).to.not.be.empty; // Vérifier que l'URL de l'image n'est pas vide
|
||||||
|
expect(img.alt).to.not.be.empty; // Vérifier que l'attribut alt n'est pas vide
|
||||||
|
});
|
||||||
|
// Fermer la modal et vérifier qu'il y a 5 images'
|
||||||
|
cy.getBySel('cart-close-button').click(); // Fermer la modal pour pouvoir séléctionner les produits en dehors
|
||||||
|
cy.getBySel('product-related').within((product) => {
|
||||||
|
// Séléctionner la section aux produits liés
|
||||||
|
cy.wrap(product).find('h2').invoke('text').should('equal', 'Related Products'); // Vérifier le titre des produits présentés
|
||||||
|
cy.getBySel('product-item').should('have.length', 5);
|
||||||
|
cy.getBySel('tile-image')
|
||||||
|
.should('exist')
|
||||||
|
.and('be.visible')
|
||||||
|
.then(($img) => {
|
||||||
|
const img = $img[0] as HTMLImageElement;
|
||||||
|
|
||||||
|
// Utiliser 'expect' pour vérifier les propriétés de l'image
|
||||||
|
expect(img).to.have.property('naturalWidth').that.is.greaterThan(0); // Vérifier la largeur naturelle
|
||||||
|
expect(img).to.have.property('naturalHeight').that.is.greaterThan(0); // Vérifier la hauteur naturelle
|
||||||
|
expect(img).to.have.property('width').that.is.greaterThan(0); // Vérifier la largeur naturelle
|
||||||
|
expect(img).to.have.property('height').that.is.greaterThan(0); // Vérifier la hauteur naturelle
|
||||||
|
expect(img.src).to.not.be.empty; // Vérifier que l'URL de l'image n'est pas vide
|
||||||
|
expect(img.alt).to.not.be.empty; // Vérifier que l'attribut alt n'est pas vide
|
||||||
|
});
|
||||||
|
});
|
||||||
|
cy.getBySel('product-item-link').eq(2).click();
|
||||||
|
|
||||||
|
cy.intercept('POST', '/product/the-collection-snowboard-oxygen').as('addItem2ToCart'); // Associer une requête réseau
|
||||||
|
cy.getBySel('submit-button').should('exist').invoke('text').should('equal', 'Add To Cart');
|
||||||
|
cy.wait(100);
|
||||||
|
cy.getBySel('submit-button').click(); // J'actionne l'ajout au panier'
|
||||||
|
cy.wait('@addItem2ToCart'); // Attendre que la requête réseau soit terminée
|
||||||
|
cy.getBySel('cart-side-container').should('exist').and('be.visible'); // Vérifier que la model est présente
|
||||||
|
|
||||||
|
// Vérification du produit
|
||||||
|
cy.getBySel('cart-item').should('exist').and('have.length', 2); // Vérifier qu'on un seul produit dans le panier'
|
||||||
|
|
||||||
|
// cy.getBySel('cart-quantity-plus-button').eq(1).click().click(); // Augmenter la quantité de 2
|
||||||
|
cy.getBySel('cart-quantity-plus-button').eq(1).click().click();
|
||||||
|
|
||||||
|
cy.getBySel('cart-product-quantity')
|
||||||
|
.should('exist')
|
||||||
|
.and('be.visible')
|
||||||
|
.eq(1)
|
||||||
|
.invoke('text')
|
||||||
|
.should('equal', '3'); // Vérifier qu'on a une quantité de 3'
|
||||||
|
|
||||||
|
cy.getBySel('cart-quantity-minus-button').eq(1).click(); // Diminier la quantité de 1
|
||||||
|
cy.getBySel('cart-product-quantity')
|
||||||
|
.should('exist')
|
||||||
|
.and('be.visible')
|
||||||
|
.eq(1)
|
||||||
|
.invoke('text')
|
||||||
|
.should('equal', '2'); // Vérifier qu'on a une quantité de 2'
|
||||||
|
|
||||||
|
// Vérifier le montant total avec le checkout
|
||||||
|
cy.getAmount(
|
||||||
|
'[data-test="payment-information"] > :nth-child(3) > [data-test="price-amount"]',
|
||||||
|
'2 924,90',
|
||||||
|
'€',
|
||||||
|
'EUR'
|
||||||
|
);
|
||||||
|
|
||||||
|
cy.getBySel('checkout-button').click();
|
||||||
|
cy.origin('https://testify-automation.myshopify.com/password', () => {
|
||||||
|
cy.url().should('equal', 'https://testify-automation.myshopify.com/password');
|
||||||
|
cy.get('#password').type('skifro');
|
||||||
|
// cy.get('button').click();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.only('Vérifier les cookies', () => {
|
||||||
|
cy.getAllCookies().then((cookies) => {
|
||||||
|
if (cookies.length > 0) {
|
||||||
|
cookies.forEach((cookie) => {
|
||||||
|
cy.log(`Nom du cookie : ${cookie.name}`);
|
||||||
|
cy.log(`Valeur du cookie : ${cookie.value}`);
|
||||||
|
console.log(`Nom du cookie : ${cookie.name}`);
|
||||||
|
console.log(`Valeur du cookie : ${cookie.value}`);
|
||||||
|
});
|
||||||
|
// Tu peux accéder à ses propriétés comme `cookie.name`, `cookie.value`, etc.}
|
||||||
|
} else {
|
||||||
|
cy.log('Pas de cookie trouvés');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,5 +0,0 @@
|
|||||||
describe('Home Page', () => {
|
|
||||||
it('displays all 3 products on the home page', () => {
|
|
||||||
cy.visit('/');
|
|
||||||
});
|
|
||||||
});
|
|
41
cypress/e2e/homepage.cy.ts
Normal file
41
cypress/e2e/homepage.cy.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { DATA_TEST_SELECTORS } from 'cypress/utils/constants';
|
||||||
|
|
||||||
|
describe('Three Item Grid Tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Visiting the home page
|
||||||
|
cy.visit('/');
|
||||||
|
});
|
||||||
|
// List all selector that will be used for the home page
|
||||||
|
const threeGridItem = () => cy.get(DATA_TEST_SELECTORS.productGridSection.items().container);
|
||||||
|
|
||||||
|
const fullSizeGridItem = () =>
|
||||||
|
cy.get(DATA_TEST_SELECTORS.productGridSection.items('full', 0).container);
|
||||||
|
|
||||||
|
// First test case: checks if the grid displays the correct items
|
||||||
|
it('Verify all grid item', () => {
|
||||||
|
threeGridItem().should('exist').and('be.visible').and('have.length', 3);
|
||||||
|
|
||||||
|
// Checking if the first 'full size' grid item exists
|
||||||
|
fullSizeGridItem().should('exist');
|
||||||
|
|
||||||
|
// Verifying the price of the first full-size grid item (good to verify both amount and currency)
|
||||||
|
const fullSizeGridItemPrice = DATA_TEST_SELECTORS.productGridSection.items('full', 0).product
|
||||||
|
.price.amount;
|
||||||
|
cy.verifyProductPrice(
|
||||||
|
fullSizeGridItemPrice,
|
||||||
|
'€', // Currency symbol to be checked
|
||||||
|
'749.95', // Expected price amount
|
||||||
|
'EUR' // Expected currency code
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Second test case: checks if the user can navigate to the correct product page
|
||||||
|
it('should navigate to the correct product page', () => {
|
||||||
|
// Clicking the first product link in the product carousel section
|
||||||
|
cy.get(DATA_TEST_SELECTORS.productCarouselSection.items(0).product.link).click();
|
||||||
|
|
||||||
|
// Asserting that the correct product page is loaded by checking the URL
|
||||||
|
cy.url().should('include', '/product/');
|
||||||
|
cy.url().should('include', '/product/the-collection-snowboard-oxygen');
|
||||||
|
});
|
||||||
|
});
|
@ -1,5 +1,61 @@
|
|||||||
{
|
{
|
||||||
"name": "Using fixtures to represent data",
|
"collections": [
|
||||||
"email": "hello@cypress.io",
|
"Automated Collection",
|
||||||
"body": "Fixtures are a great way to mock data for responses to routes"
|
"hidden-homepage-carousel",
|
||||||
|
"hidden-homepage-featured-items",
|
||||||
|
"Home page",
|
||||||
|
"Hydrogen"
|
||||||
|
],
|
||||||
|
"products": [
|
||||||
|
{
|
||||||
|
"published": true,
|
||||||
|
"name": "Selling Plans Ski Wax",
|
||||||
|
"grams": 56.69904625,
|
||||||
|
"price": 24.95,
|
||||||
|
"gift_card": false,
|
||||||
|
"image_src": "https://cdn.shopify.com/s/files/1/0841/2905/5048/files/snowboard_wax.png?v=1724052616",
|
||||||
|
"variant_weight_unit": "oz",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"published": null,
|
||||||
|
"name": "Special Selling Plans Ski Wax",
|
||||||
|
"grams": 70.8738078125,
|
||||||
|
"price": 49.95,
|
||||||
|
"gift_card": null,
|
||||||
|
"image_src": "https://cdn.shopify.com/s/files/1/0841/2905/5048/files/wax-special.png?v=1724052616",
|
||||||
|
"variant_weight_unit": "oz",
|
||||||
|
"status": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"published": null,
|
||||||
|
"name": "Sample Selling Plans Ski Wax",
|
||||||
|
"grams": 14.1747615625,
|
||||||
|
"price": 9.95,
|
||||||
|
"gift_card": null,
|
||||||
|
"image_src": "https://cdn.shopify.com/s/files/1/0841/2905/5048/files/sample-normal-wax.png?v=1724052616",
|
||||||
|
"variant_weight_unit": "oz",
|
||||||
|
"status": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"published": true,
|
||||||
|
"name": "Default Title",
|
||||||
|
"grams": 0.0,
|
||||||
|
"price": 749.95,
|
||||||
|
"gift_card": false,
|
||||||
|
"image_src": "https://cdn.shopify.com/s/files/1/0841/2905/5048/files/Main_b13ad453-477c-4ed1-9b43-81f3345adfd6.jpg?v=1724052614",
|
||||||
|
"variant_weight_unit": "kg",
|
||||||
|
"status": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"published": true,
|
||||||
|
"name": "Default Title",
|
||||||
|
"grams": 0.0,
|
||||||
|
"price": 1025.0,
|
||||||
|
"gift_card": false,
|
||||||
|
"image_src": "https://cdn.shopify.com/s/files/1/0841/2905/5048/files/Main_d624f226-0a89-4fe1-b333-0d1548b43c06.jpg?v=1724052611",
|
||||||
|
"variant_weight_unit": "kg",
|
||||||
|
"status": "active"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
6
cypress/fixtures/product-query.json
Normal file
6
cypress/fixtures/product-query.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"query": "query getProduct($productName: String!) { products(first: 1, query: $productName) { edges { node { title totalInventory variants(first: 10) { edges { node { title price { amount currencyCode } } } } } } } }",
|
||||||
|
"variables": {
|
||||||
|
"productName": ""
|
||||||
|
}
|
||||||
|
}
|
@ -1,37 +1,62 @@
|
|||||||
/// <reference types="cypress" />
|
import { destructPrice, omitSubstrings as getFloat } from '../utils/price';
|
||||||
// ***********************************************
|
|
||||||
// This example commands.ts shows you how to
|
declare global {
|
||||||
// create various custom commands and overwrite
|
namespace Cypress {
|
||||||
// existing commands.
|
interface Chainable {
|
||||||
//
|
|
||||||
// For more comprehensive examples of custom
|
|
||||||
// commands please read more here:
|
|
||||||
// https://on.cypress.io/custom-commands
|
|
||||||
// ***********************************************
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This is a parent command --
|
|
||||||
// Cypress.Commands.add('login', (email, password) => { ... })
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This is a child command --
|
|
||||||
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This is a dual command --
|
|
||||||
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// -- This will overwrite an existing command --
|
|
||||||
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
|
||||||
//
|
|
||||||
// declare global {
|
|
||||||
// namespace Cypress {
|
|
||||||
// interface Chainable {
|
|
||||||
// login(email: string, password: string): Chainable<void>
|
// login(email: string, password: string): Chainable<void>
|
||||||
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
// drag(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||||
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
// dismiss(subject: string, options?: Partial<TypeOptions>): Chainable<Element>
|
||||||
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
|
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
|
||||||
// }
|
getBySel(
|
||||||
// }
|
selector: string,
|
||||||
// }
|
...args: Partial<
|
||||||
|
Cypress.Loggable & Cypress.Timeoutable & Cypress.Withinable & Cypress.Shadow
|
||||||
|
>[]
|
||||||
|
): Chainable<JQuery<HTMLElement>>;
|
||||||
|
|
||||||
|
verifyProductPrice(
|
||||||
|
selector: string,
|
||||||
|
expectedCurrencySymbol: string,
|
||||||
|
expectedPrice: string,
|
||||||
|
expectedCurrencyCode: string
|
||||||
|
): Chainable<void>;
|
||||||
|
|
||||||
|
getAmount(selector: string, priceExpected: string, ...substrings: string[]): Chainable<void>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Cypress.Commands.add('getBySel', (selector, ...args) => {
|
||||||
|
return cy.get(`[data-test="${selector}"]`, ...args);
|
||||||
|
});
|
||||||
|
|
||||||
|
Cypress.Commands.add(
|
||||||
|
'verifyProductPrice',
|
||||||
|
(
|
||||||
|
selector: string,
|
||||||
|
expectedCurrencySymbol: string,
|
||||||
|
expectedPrice: string,
|
||||||
|
expectedCurrencyCode: string
|
||||||
|
) => {
|
||||||
|
cy.get(selector)
|
||||||
|
.should('be.visible')
|
||||||
|
.invoke('text')
|
||||||
|
.then((amount) => {
|
||||||
|
const { currencySymbol, price, currencyCode } = destructPrice(amount);
|
||||||
|
expect(currencySymbol).to.equal(expectedCurrencySymbol);
|
||||||
|
expect(price).to.equal(expectedPrice.toString());
|
||||||
|
expect(currencyCode).to.equal(expectedCurrencyCode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Cypress.Commands.add('getAmount', (selector: string, amountExpected: string) => {
|
||||||
|
cy.get(selector)
|
||||||
|
.should('exist')
|
||||||
|
.and('be.visible')
|
||||||
|
.invoke('text')
|
||||||
|
.then((text) => {
|
||||||
|
const amount = text.replace(/[^\d,\.]/g, '');
|
||||||
|
expect(amount).to.be.equal(amountExpected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -14,7 +14,7 @@
|
|||||||
// ***********************************************************
|
// ***********************************************************
|
||||||
|
|
||||||
// Import commands.js using ES2015 syntax:
|
// Import commands.js using ES2015 syntax:
|
||||||
import './commands'
|
import './commands';
|
||||||
|
|
||||||
// Alternatively you can use CommonJS syntax:
|
// Alternatively you can use CommonJS syntax:
|
||||||
// require('./commands')
|
// require('./commands')
|
7
cypress/support/step_definitions/common.ts
Normal file
7
cypress/support/step_definitions/common.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { Given } from '@badeball/cypress-cucumber-preprocessor';
|
||||||
|
|
||||||
|
// Étape pour aller à la page d'accueil
|
||||||
|
|
||||||
|
Given('I am on the homepage', () => {
|
||||||
|
cy.visit('/');
|
||||||
|
});
|
59
cypress/utils/constants.ts
Normal file
59
cypress/utils/constants.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
//---------------------------------------------------------------------------------------------------------------------------------------------------------------\\
|
||||||
|
/**
|
||||||
|
* Generates test identifiers for a grid item, reflecting the product elements.
|
||||||
|
* @param itemSize - The size of the grid item ('full' or 'half').
|
||||||
|
* @param itemIndex - Optional index of the item in the grid.
|
||||||
|
* @returns An object containing test identifiers mapped to product elements in the grid.
|
||||||
|
*/
|
||||||
|
function generateGridItemTestIds(itemSize?: 'full' | 'half' | undefined, itemIndex?: number) {
|
||||||
|
const size = itemSize !== undefined ? `-${itemSize}` : '';
|
||||||
|
const suffix = itemIndex !== undefined ? `-${itemIndex}` : '';
|
||||||
|
const itemSelector = `[data-test^="grid-item${size}${suffix}"]`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
container: itemSelector,
|
||||||
|
product: {
|
||||||
|
link: `${itemSelector} > [data-test="product-link"]`,
|
||||||
|
image: `${itemSelector} > [data-test="product-link"] > [data-test="tile-container"] > [data-test="tile-image"]`,
|
||||||
|
name: `${itemSelector} > [data-test="product-link"] > [data-test="tile-container"] > [data-test="label-container"] > [data-test="label-content-wrapper"] > [data-test="label-title-text"]`,
|
||||||
|
price: {
|
||||||
|
amount: `${itemSelector} > [data-test="product-link"] > [data-test="tile-container"] > [data-test="label-container"] > [data-test="label-content-wrapper"] > [data-test="price-amount"]`,
|
||||||
|
currencyCode: `${itemSelector} > [data-test="product-link"] > [data-test="tile-container"] > [data-test="label-container"] > [data-test="label-content-wrapper"] > [data-test="price-currency-code"]`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates test identifiers for a carousel item, reflecting the product elements.
|
||||||
|
* @param itemIndex - The index of the item in the carousel.
|
||||||
|
* @returns An object containing test identifiers mapped to product elements in the carousel.
|
||||||
|
*/
|
||||||
|
function generateCarouselItemTestIds(itemIndex: number) {
|
||||||
|
const itemSelector = `[data-test="carousel-item-${itemIndex}"]`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
container: itemSelector,
|
||||||
|
product: {
|
||||||
|
link: `${itemSelector} > [data-test="product-link"]`,
|
||||||
|
image: `${itemSelector} > [data-test="tile-container"] > [data-test="tile-image"]`,
|
||||||
|
name: `${itemSelector} > [data-test="tile-container"] > [data-test="label-container"] > [data-test="label-content-wrapper"] > [data-test="label-title-text"]`,
|
||||||
|
price: {
|
||||||
|
amount: `${itemSelector} > [data-test="tile-container"] > [data-test="label-container"] > [data-test="label-content-wrapper"] > [data-test="price-amount"]`,
|
||||||
|
currencyCode: `${itemSelector} > [data-test="tile-container"] > [data-test="label-container"] > [data-test="label-content-wrapper"] > [data-test="price-currency-code"]`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DATA_TEST_SELECTORS = {
|
||||||
|
productGridSection: {
|
||||||
|
container: '[data-test="three-item-grid"]',
|
||||||
|
items: (size?: 'full' | 'half' | undefined, index?: number) =>
|
||||||
|
generateGridItemTestIds(size, index)
|
||||||
|
},
|
||||||
|
productCarouselSection: {
|
||||||
|
container: '[data-test="carousel-container"]',
|
||||||
|
items: (index: number) => generateCarouselItemTestIds(index)
|
||||||
|
}
|
||||||
|
} as const;
|
19
cypress/utils/price.ts
Normal file
19
cypress/utils/price.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export const destructPrice = (
|
||||||
|
amount: string
|
||||||
|
): { currencySymbol?: string; price?: string; currencyCode?: string } => {
|
||||||
|
const currencySymbol = amount.substring(0, 1);
|
||||||
|
const price = amount.substring(1, amount.length - 3);
|
||||||
|
const currencyCode = amount.slice(-3);
|
||||||
|
return { currencySymbol, price, currencyCode };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const omitSubstrings = (str: string, ...substrings: string[]): string => {
|
||||||
|
let result = str;
|
||||||
|
|
||||||
|
substrings.forEach((substring) => {
|
||||||
|
const regex = new RegExp(substring, 'g');
|
||||||
|
result = result.replace(regex, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.trim();
|
||||||
|
};
|
0
cypress/utils/selectors.ts
Normal file
0
cypress/utils/selectors.ts
Normal file
16
cypress/utils/url.ts
Normal file
16
cypress/utils/url.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export function toSlug(input: string): string {
|
||||||
|
// 1. Convertir la chaîne en minuscules
|
||||||
|
let slug = input.toLowerCase();
|
||||||
|
|
||||||
|
// 2. Remplacer les caractères accentués ou spéciaux par leurs équivalents non accentués
|
||||||
|
slug = slug.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
|
||||||
|
// 3. Remplacer tous les caractères non alphanumériques par des tirets
|
||||||
|
slug = slug.replace(/[^a-z0-9]+/g, '-');
|
||||||
|
|
||||||
|
// 4. Supprimer les tirets de début ou de fin
|
||||||
|
return slug.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
export const verifyRequest = (alias: string) => {
|
||||||
|
cy.wait(alias).its('response.statusCode').should('eq', 200);
|
||||||
|
};
|
10
docs/tests/homepage.md
Normal file
10
docs/tests/homepage.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
Feature: Affichage des produits sur la page d'accueil
|
||||||
|
|
||||||
|
Scenario: Vérification de l'affichage des produits avec nom, image et prix
|
||||||
|
Given I am on the homepage
|
||||||
|
When the page is loaded
|
||||||
|
Then I should see a list of products
|
||||||
|
And each product should have an image
|
||||||
|
And each product should have a name
|
||||||
|
And each product should have a price
|
||||||
|
And the price should be displayed in EUR with two decimal points
|
99
docs/tests/test-design.md
Normal file
99
docs/tests/test-design.md
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
# Conception des Tests
|
||||||
|
|
||||||
|
## Hiérarchie des Composants et Attributs Data-Test
|
||||||
|
|
||||||
|
### Home page
|
||||||
|
|
||||||
|
#### ThreeItemGrid Component
|
||||||
|
|
||||||
|
Voici un schéma Mermaid qui représente les composants et les hiérarchies des attributs data-test pour le composant `ThreeItemGrid`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
%%{init: {'theme': 'dark'}}%%
|
||||||
|
|
||||||
|
graph TD
|
||||||
|
|
||||||
|
%% Using a darker green (#339966) with white text (#fff) for better contrast
|
||||||
|
classDef grid-item-class fill:#339966,stroke:#aaa,stroke-width:2px,color:#fff;
|
||||||
|
|
||||||
|
%% Dark blue (#336699) with white text (#fff) for containers
|
||||||
|
classDef container-class fill:#336699,stroke:#aaa,stroke-width:2px,color:#fff;
|
||||||
|
|
||||||
|
%% Dark red (#cc3333) with white text (#fff) for React components
|
||||||
|
classDef react-component-class fill:#cc3333,stroke:#aaa,stroke-width:2px,color:#fff;
|
||||||
|
|
||||||
|
%% Subgraph representing the overall grid structure
|
||||||
|
subgraph ThreeItemGrid
|
||||||
|
style ThreeItemGrid fill:#333,stroke:#aaa,stroke-width:2px;
|
||||||
|
|
||||||
|
%% Grid Item with configurable size
|
||||||
|
ThreeItemGridItem[⚛️ Three Item Grids]
|
||||||
|
class ThreeItemGridItem react-component-class
|
||||||
|
|
||||||
|
GridItem[🗂️ data-test: three-item-grid]
|
||||||
|
ThreeItemGridItem --> GridItem
|
||||||
|
class GridItem container-class
|
||||||
|
|
||||||
|
GridItemSize[🔍 data-test: grid-item <br>⬛ full / ⬜ half]
|
||||||
|
GridItem --> GridItemSize
|
||||||
|
class GridItemSize grid-item-class
|
||||||
|
|
||||||
|
%% Grid Product Link for GridItemSize
|
||||||
|
GridItemSize --> GridProductLink[🔍 data-test: grid-product-link]
|
||||||
|
class GridProductLink grid-item-class
|
||||||
|
|
||||||
|
GridProductLink --> GridTileImage[⚛️ GridTileImage]
|
||||||
|
class GridTileImage react-component-class
|
||||||
|
|
||||||
|
GridTileImage --> GridTileContainer[🗂️ data-test: grid-tile-container]
|
||||||
|
class GridTileContainer container-class
|
||||||
|
|
||||||
|
GridTileContainer --> GridTileImageItem[🔍 data-test: grid-tile-image]
|
||||||
|
class GridTileImageItem grid-item-class
|
||||||
|
|
||||||
|
GridTileContainer --> Label[⚛️ Label]
|
||||||
|
class Label react-component-class
|
||||||
|
|
||||||
|
Label --> LabelContainer[🗂️ data-test: label-container]
|
||||||
|
class LabelContainer container-class
|
||||||
|
|
||||||
|
LabelContainer --> LabelContentWrapper[🗂️ data-test: label-content-wrapper]
|
||||||
|
class LabelContentWrapper container-class
|
||||||
|
|
||||||
|
LabelContentWrapper --> LabelTitleText[🔍 data-test: label-title-text]
|
||||||
|
class LabelTitleText grid-item-class
|
||||||
|
|
||||||
|
LabelContentWrapper --> Price[⚛️ Price]
|
||||||
|
class Price react-component-class
|
||||||
|
|
||||||
|
Price --> PriceAmount[🔍 data-test: price-amount]
|
||||||
|
class PriceAmount grid-item-class
|
||||||
|
|
||||||
|
PriceAmount --> PriceCurrencyCode[🔍 data-test: price-currency-code]
|
||||||
|
class PriceCurrencyCode grid-item-class
|
||||||
|
end
|
||||||
|
|
||||||
|
%% Adding explanatory notes as special nodes
|
||||||
|
Note1[Note: This component represents a set of products displayed in a grid format.]
|
||||||
|
Note2[Note: This is the main container of the grid that encompasses all product elements.]
|
||||||
|
Note3["Note: Defines the size of the item in the grid. Two sizes are possible:
|
||||||
|
⬛ full or ⬜ half."]
|
||||||
|
Note4[Note: Link to the specific product detail page.]
|
||||||
|
Note5[Note: Component that displays the main image of the product in the grid.]
|
||||||
|
Note6[Note: Container for the product image, labels, and other information.]
|
||||||
|
Note7[Note: Component to display the textual information of the product, such as the title and price.]
|
||||||
|
Note8[Note: Displays the product name in the grid.]
|
||||||
|
Note9[Note: Displays the product price amount with the currency.]
|
||||||
|
|
||||||
|
ThreeItemGridItem --> Note1
|
||||||
|
GridItem --> Note2
|
||||||
|
GridItemSize --> Note3
|
||||||
|
GridProductLink --> Note4
|
||||||
|
GridTileImage --> Note5
|
||||||
|
GridTileContainer --> Note6
|
||||||
|
Label --> Note7
|
||||||
|
LabelTitleText --> Note8
|
||||||
|
PriceAmount --> Note9
|
||||||
|
```
|
||||||
|
|
||||||
|
[](https://mermaid-js.github.io/mermaid-live-editor/edit#pako:eNqdV92K4zYUfhXhJUwX4rTO3yZuWVhmoCzslqWTUuh4LhRbTsTYkpHkzmSHuel92ZuFQrfQF9gH6PPMC7SP0CPJVvyTZGiHYSbROef7Pp1zrCPfezFPiBd6g8E9ZVSF6P5MbUlOzkJ0lmBxc_bwMBhELGIbgYstWl3oz4MB-kFStkEYaR8i0EYQwtAXzyaT5XI-f45uqdqi2y1VBClyp8CSpulzlHKB1kQpiIg5UwJLFbE4w1JekBRAaOJDSO6bJZTSLAsryKFUgt-Q8BnGuPrs39JEbcNxcTeMecZFqDm-rvRdgC60zkpiRM3ny-VJUVoNpowI2dDjFjt6NNr_0iNIAqRxPIGfk3K-JzhWwJ8XnBGmmqKENvnO1JJmkf-rtMtybYsrSCGIBFBdWugCxH8mAmeZKQwCpDJWpSARk3XEagt1fw0V-xY8IobgR6pdRtoGl7jT0oweDQGaTJiOt1mCSqR0Uwq8BmxJ3xPr2GLR_68ef__0918frMHGa5O8tv42V72owzmt1Tjsf_787RcNnmCFfUUkPCxKQ9mW1Tm6PiIL-f5Lh9OU4hw6rdblvoQtA__HX5vk7nFB36zFy8fPn1BaQrG-RI-f_0BbnKXXbZCWikuXxLYSvd59ELt1eSd4Aq2A3lB2Y9q1D9qCq3mrOB12eDOFdfAz8LjuqmtEHxPYdauJVzQjr3O8IXV_tBZ7TM7ydGPsXZtc53U1D_WM0a7Ay3dFP6jAgZxqjrZjb79V3x5ItRFAT2_fNM2JVPfJ3-A1yeokmy8tdLNyOqnWxWGdzGSmPY5ksR19LIUdrxYtSPsRDrniKW69hVvreFjBHuhJGQ1Xp2VFVUZWMCB6hbQalLb7eoL06V3ssTIeY34naOyeFvOlBW5WThfSujisVzkvWX8Lhbb52Bj7DDbomPami-M5L4UgLN6dw7XmCFtcufj66tMnbSL0qQlLqrH5Kkn0nCR3RYYZVlzsEOPAg7BEsiAxxRksJASCvoP14Er_DWE4ULmf7PuZC3FIEoV4iqpjUKKESgDfwa2BMjCbMQznbY7V6Nqijpuo8Kundg4dtu80DahXTbDaYoVgc0APu9FaYWBUdIhkcO8DITX25CryLDrcOwDKouvpW2Oa4QNkNf4IrW658QBkQVDBpaQwskPkphNMi3o8jSKvYppWuzCHtuKWR6cwpbGTlxDYEKiFg6lWOKvizl06zQartDWyYQ66WnUN2BReAc4dYJ09Pd5aQRppaB89OUSYJYiDXQCarQzlrEZ70ZfHa3EGVD-0JbRJI7YjcohkGW91Sxl__TgbTtPJNc-i4rlo7rsWzHBODm11eSrIwCP7WNobmLbWD46GiNjhW45p9Yj11sb7NXcnME1m17tT23SFNbVHrCn73tA-u00JI7afIaYK1cL-LKwti4h1zxCTGW_o5QTqQRN4MbrXB0TkmZeiyAvhIyMlvLlkkRexB3DFpeKXOxZ7IVyQydATvNxsvTDFmYRvZQHnD7mgGC7MuVstMPuJ87wOIQmF8-OtfRMzL2TGxQvvvTsvnM1HwXwcTCazxXQ8nwXjobfzwmA8G71YLMdfzaaz6XQ5DsYPQ--9AQ1GwXKynC4XwWIeBMF09vAvo7n2MQ)
|
@ -214,6 +214,7 @@ export async function addToCart(
|
|||||||
cartId: string,
|
cartId: string,
|
||||||
lines: { merchandiseId: string; quantity: number }[]
|
lines: { merchandiseId: string; quantity: number }[]
|
||||||
): Promise<Cart> {
|
): Promise<Cart> {
|
||||||
|
try {
|
||||||
const res = await shopifyFetch<ShopifyAddToCartOperation>({
|
const res = await shopifyFetch<ShopifyAddToCartOperation>({
|
||||||
query: addToCartMutation,
|
query: addToCartMutation,
|
||||||
variables: {
|
variables: {
|
||||||
@ -222,7 +223,13 @@ export async function addToCart(
|
|||||||
},
|
},
|
||||||
cache: 'no-store'
|
cache: 'no-store'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('Response from Shopify:', res.status); // Log après réception de la réponse
|
||||||
return reshapeCart(res.body.data.cartLinesAdd.cart);
|
return reshapeCart(res.body.data.cartLinesAdd.cart);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during add to cart:', error); // Log en cas d'erreur
|
||||||
|
throw new Error('Failed to add item to cart');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
|
export async function removeFromCart(cartId: string, lineIds: string[]): Promise<Cart> {
|
||||||
|
20
package.json
20
package.json
@ -10,32 +10,32 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"prettier": "prettier --write --ignore-unknown .",
|
"prettier": "prettier --write --ignore-unknown .",
|
||||||
"prettier:check": "prettier --check --ignore-unknown .",
|
"prettier:check": "prettier --check --ignore-unknown .",
|
||||||
"test": "pnpm prettier:check",
|
"test": "pnpm prettier:check"
|
||||||
"cypress:open": "cypress open",
|
|
||||||
"cypress:run": "cypress run"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.1.2",
|
"@headlessui/react": "^2.1.2",
|
||||||
"@heroicons/react": "^2.1.5",
|
"@heroicons/react": "^2.1.5",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"geist": "^1.3.1",
|
"geist": "^1.3.1",
|
||||||
"next": "15.0.0-canary.113",
|
"next": "15.0.0-canary.155",
|
||||||
"react": "19.0.0-rc-3208e73e-20240730",
|
"react": "19.0.0-rc-206df66e-20240912",
|
||||||
"react-dom": "19.0.0-rc-3208e73e-20240730",
|
"react-dom": "19.0.0-rc-206df66e-20240912",
|
||||||
"sonner": "^1.5.0"
|
"sonner": "^1.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@badeball/cypress-cucumber-preprocessor": "^20.1.2",
|
||||||
|
"@bahmutov/cypress-esbuild-preprocessor": "^2.2.3",
|
||||||
"@tailwindcss/container-queries": "^0.1.1",
|
"@tailwindcss/container-queries": "^0.1.1",
|
||||||
"@tailwindcss/typography": "^0.5.13",
|
"@tailwindcss/typography": "^0.5.13",
|
||||||
"@types/node": "20.14.12",
|
"@types/node": "22.5.5",
|
||||||
"@types/react": "18.3.3",
|
"@types/react": "18.3.5",
|
||||||
"@types/react-dom": "18.3.0",
|
"@types/react-dom": "18.3.0",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"cypress": "^13.13.3",
|
"cypress": "^13.14.2",
|
||||||
"postcss": "^8.4.39",
|
"postcss": "^8.4.39",
|
||||||
"prettier": "3.3.3",
|
"prettier": "3.3.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||||
"tailwindcss": "^3.4.6",
|
"tailwindcss": "^3.4.6",
|
||||||
"typescript": "5.5.4"
|
"typescript": "5.6.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
2638
pnpm-lock.yaml
generated
2638
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2015",
|
"target": "ES2022",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["ES2022", "dom"],
|
||||||
"downlevelIteration": true,
|
"downlevelIteration": true,
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
@ -9,20 +9,27 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
|
"module": "esnext",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"types": ["cypress", "node", "@types/node", "@badeball/cypress-cucumber-preprocessor"],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "next"
|
"name": "next"
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@badeball/cypress-cucumber-preprocessor/*": [
|
||||||
|
"./node_modules/@badeball/cypress-cucumber-preprocessor/dist/subpath-entrypoints/*"
|
||||||
]
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "cypress/**/*.ts"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user