From 11b12277c9aaf8654b55d0f30cfdce3ea5d013db Mon Sep 17 00:00:00 2001 From: Robert Field Date: Thu, 18 Jan 2024 16:30:07 +0000 Subject: [PATCH] feat: generated latest examples --- examples/algolia/README.md | 2 +- examples/algolia/e2e/checkout-flow.spec.ts | 48 +++ .../algolia/e2e/models/d2c-checkout-page.ts | 55 ++++ .../e2e/util/enter-payment-information.ts | 10 + examples/algolia/e2e/util/gateway-check.ts | 13 + .../algolia/e2e/util/gateway-is-enabled.ts | 10 + examples/algolia/package.json | 31 +- .../account-memeber-credentials-schema.ts | 23 ++ examples/algolia/src/app/(auth)/actions.ts | 200 +++++++++++++ examples/algolia/src/app/(auth)/layout.tsx | 51 ++++ .../src/app/(auth)/login/LoginForm.tsx | 78 +++++ .../algolia/src/app/(auth)/login/page.tsx | 49 ++++ examples/algolia/src/app/(auth)/not-found.tsx | 13 + .../algolia/src/app/(auth)/register/page.tsx | 97 ++++++ .../(checkout)/checkout/AccountCheckout.tsx | 52 ++++ .../(checkout)/checkout/AccountDisplay.tsx | 82 ++++++ .../app/(checkout)/checkout/BillingForm.tsx | 233 +++++++++++++++ .../(checkout)/checkout/CheckoutFooter.tsx | 30 ++ .../(checkout)/checkout/CheckoutSidebar.tsx | 102 +++++++ .../app/(checkout)/checkout/CheckoutViews.tsx | 15 + .../checkout/ConfirmationSidebar.tsx | 108 +++++++ .../app/(checkout)/checkout/DeliveryForm.tsx | 108 +++++++ .../src/app/(checkout)/checkout/FormInput.tsx | 73 +++++ .../app/(checkout)/checkout/GuestCheckout.tsx | 51 ++++ .../(checkout)/checkout/GuestInformation.tsx | 56 ++++ .../(checkout)/checkout/OrderConfirmation.tsx | 87 ++++++ .../app/(checkout)/checkout/PaymentForm.tsx | 30 ++ .../app/(checkout)/checkout/ShippingForm.tsx | 217 ++++++++++++++ .../(checkout)/checkout/ShippingSelector.tsx | 99 +++++++ .../checkout/SubmitCheckoutButton.tsx | 27 ++ .../(checkout)/checkout/checkout-provider.tsx | 103 +++++++ .../src/app/(checkout)/checkout/page.tsx | 47 +++ .../checkout/usePaymentComplete.tsx | 129 ++++++++ .../(checkout)/checkout/useShippingMethod.tsx | 39 +++ .../algolia/src/app/(checkout)/layout.tsx | 51 ++++ .../algolia/src/app/(checkout)/not-found.tsx | 13 + .../src/app/{ => (store)}/about/page.tsx | 2 +- .../app/(store)/account/AccountNavigation.tsx | 54 ++++ .../account/addresses/[addressId]/page.tsx | 236 +++++++++++++++ .../app/(store)/account/addresses/actions.ts | 200 +++++++++++++ .../(store)/account/addresses/add/page.tsx | 209 +++++++++++++ .../app/(store)/account/addresses/page.tsx | 107 +++++++ .../src/app/(store)/account/breadcrumb.tsx | 85 ++++++ .../src/app/(store)/account/layout.tsx | 17 ++ .../app/(store)/account/orders/OrderItem.tsx | 70 +++++ .../account/orders/OrderItemWithDetails.tsx | 32 ++ .../orders/[orderId]/OrderLineItem.tsx | 42 +++ .../orders/[orderId]/ProductThumbnail.tsx | 22 ++ .../(store)/account/orders/[orderId]/page.tsx | 206 +++++++++++++ .../src/app/(store)/account/orders/page.tsx | 128 ++++++++ .../(store)/account/summary/YourInfoForm.tsx | 59 ++++ .../app/(store)/account/summary/actions.ts | 220 ++++++++++++++ .../src/app/(store)/account/summary/page.tsx | 113 +++++++ .../algolia/src/app/(store)/cart/CartItem.tsx | 62 ++++ .../src/app/(store)/cart/CartItemWide.tsx | 62 ++++ .../src/app/(store)/cart/CartSidebar.tsx | 45 +++ .../algolia/src/app/(store)/cart/CartView.tsx | 52 ++++ .../algolia/src/app/(store)/cart/YourBag.tsx | 23 ++ .../algolia/src/app/(store)/cart/page.tsx | 5 + .../src/app/(store)}/faq/page.tsx | 2 +- examples/algolia/src/app/(store)/layout.tsx | 64 ++++ .../algolia/src/app/(store)/not-found.tsx | 13 + .../app => algolia/src/app/(store)}/page.tsx | 4 +- .../products/[productId]/page.tsx | 11 +- .../products/[productId]/product-display.tsx | 30 +- .../search/[[...node]]/layout.tsx | 2 +- .../{ => (store)}/search/[[...node]]/page.tsx | 0 .../src/app/{ => (store)}/search/search.tsx | 14 +- .../src/app/(store)}/shipping/page.tsx | 2 +- .../src/app/(store)}/terms/page.tsx | 2 +- examples/algolia/src/app/cart/page.tsx | 42 --- examples/algolia/src/app/layout.tsx | 53 +--- examples/algolia/src/app/providers.tsx | 46 ++- examples/algolia/src/components/Checkbox.tsx | 29 ++ .../algolia/src/components/LoadingDots.tsx | 13 + .../src/components/accordion/Accordion.tsx | 51 ++++ .../algolia/src/components/alert/Alert.tsx | 59 ++++ .../algolia/src/components/button/Button.tsx | 68 +++++ .../components/button/FormStatusButton.tsx | 41 +++ .../src/components/button/LoaderIcon.tsx | 17 ++ .../src/components/button/StatusButton.tsx | 47 +++ .../src/components/button/TextButton.tsx | 41 +++ examples/algolia/src/components/cart/Cart.tsx | 38 --- .../src/components/cart/CartDiscounts.tsx | 94 ++++++ .../src/components/cart/CartItemList.tsx | 65 ---- .../src/components/cart/CartOrderSummary.tsx | 80 ----- .../algolia/src/components/cart/CartSheet.tsx | 166 +++++++++++ .../algolia/src/components/cart/Promotion.tsx | 59 ---- .../components/checkout-item/CheckoutItem.tsx | 32 ++ .../checkout-sidebar/AddPromotion.tsx | 73 +++++ .../checkout-sidebar/ItemSidebar.tsx | 166 +++++++++++ .../components/checkout-sidebar/actions.ts | 43 +++ .../form-schema/checkout-form-schema.ts | 90 ++++++ examples/algolia/src/components/form/Form.tsx | 183 ++++++++++++ .../components/header/AccountMobileMenu.tsx | 159 ++++++++++ .../algolia/src/components/header/Header.tsx | 11 +- .../components/header/account/AccountMenu.tsx | 194 ++++++++++++ .../header/account/AccountSwitcher.tsx | 52 ++++ .../header/account/switch-button.tsx | 57 ++++ .../src/components/header/cart/CartMenu.tsx | 115 -------- .../components/header/cart/ModalCartItem.tsx | 168 ----------- .../navigation/MobileAccountSwitcher.tsx | 70 +++++ .../header/navigation/MobileNavBar.tsx | 4 +- .../header/navigation/MobileNavBarButton.tsx | 75 +++-- .../header/navigation/NavBarPopover.tsx | 102 +++++-- .../header/navigation/NavItemContent.tsx | 16 +- .../components/header/navigation/NavMenu.tsx | 133 +++------ .../algolia/src/components/input/Input.tsx | 41 +++ .../algolia/src/components/label/Label.tsx | 18 ++ .../navigation-menu/NavigationMenu.tsx | 122 ++++++++ .../number-input/EditQuantityButton.tsx | 55 ++++ .../components/number-input/NumberInput.tsx | 50 ++++ .../components/number-input/use-counter.ts | 277 ++++++++++++++++++ .../src/components/pagination/Pagination.tsx | 120 ++++++++ .../pagination/ResourcePagination.tsx | 114 +++++++ .../src/components/product/CartActions.tsx | 35 --- .../components/product/ProductContainer.tsx | 42 --- .../src/components/product/ProductSummary.tsx | 1 - .../src/components/product/SimpleProduct.tsx | 48 ++- .../product/bundles/BundleProduct.tsx | 55 +++- .../product/bundles/ProductComponent.tsx | 1 + .../product/variations/VariationProduct.tsx | 52 ++-- .../quantity-handler/QuantityHandler.tsx | 44 --- .../src/components/radio-group/RadioGroup.tsx | 43 +++ .../src/components/search/MobileFilters.tsx | 2 +- .../src/components/search/Pagination.tsx | 2 +- .../src/components/search/SearchModal.tsx | 228 -------------- .../algolia/src/components/select/Select.tsx | 183 ++++++++++++ .../src/components/separator/Separator.tsx | 29 ++ .../algolia/src/components/sheet/Sheet.tsx | 134 +++++++++ .../src/components/skeleton/Skeleton.tsx | 15 + examples/algolia/src/hooks/use-countries.tsx | 18 ++ examples/algolia/src/lib/all-countries.ts | 258 ++++++++++++++++ .../algolia/src/lib/cart-cookie-client.ts | 17 -- examples/algolia/src/lib/cn.tsx | 6 + examples/algolia/src/lib/constants.ts | 6 + examples/algolia/src/lib/cookie-constants.ts | 1 + examples/algolia/src/lib/ep-client-store.ts | 27 -- .../algolia/src/lib/epcc-server-client.ts | 2 +- .../epcc-server-side-credentials-client.ts | 49 ++++ examples/algolia/src/lib/format-currency.tsx | 20 ++ .../algolia/src/lib/format-iso-date-string.ts | 8 + examples/algolia/src/lib/get-error-message.ts | 15 + ...-context.ts => get-store-initial-state.ts} | 7 +- .../lib/is-account-member-authenticated.ts | 13 + examples/algolia/src/lib/parse-cookie.ts | 32 -- .../src/lib/providers/store-provider.tsx | 19 -- .../src/lib/resolve-shopping-cart-props.ts | 29 -- .../retrieve-account-member-credentials.ts | 49 ++++ .../algolia/src/lib/types/store-context.ts | 17 -- examples/algolia/src/services/cart.ts | 13 - examples/algolia/src/styles/globals.css | 37 --- examples/algolia/tailwind.config.ts | 38 ++- examples/algolia/tsconfig.json | 3 +- examples/payments/README.md | 2 +- examples/payments/package.json | 27 +- .../account-memeber-credentials-schema.ts | 23 ++ examples/payments/src/app/(auth)/actions.ts | 200 +++++++++++++ examples/payments/src/app/(auth)/layout.tsx | 51 ++++ .../src/app/(auth)/login/LoginForm.tsx | 78 +++++ .../payments/src/app/(auth)/login/page.tsx | 49 ++++ .../payments/src/app/(auth)/not-found.tsx | 13 + .../payments/src/app/(auth)/register/page.tsx | 97 ++++++ .../(checkout)/checkout/AccountCheckout.tsx | 52 ++++ .../(checkout)/checkout/AccountDisplay.tsx | 82 ++++++ .../app/(checkout)/checkout/BillingForm.tsx | 233 +++++++++++++++ .../(checkout)/checkout/CheckoutFooter.tsx | 30 ++ .../(checkout)/checkout/CheckoutSidebar.tsx | 102 +++++++ .../app/(checkout)/checkout/CheckoutViews.tsx | 15 + .../checkout/ConfirmationSidebar.tsx | 108 +++++++ .../app/(checkout)/checkout/DeliveryForm.tsx | 108 +++++++ .../src/app/(checkout)/checkout/FormInput.tsx | 73 +++++ .../app/(checkout)/checkout/GuestCheckout.tsx | 51 ++++ .../(checkout)/checkout/GuestInformation.tsx | 56 ++++ .../(checkout)/checkout/OrderConfirmation.tsx | 87 ++++++ .../app/(checkout)/checkout/PaymentForm.tsx | 14 + .../app/(checkout)/checkout/ShippingForm.tsx | 217 ++++++++++++++ .../(checkout)/checkout/ShippingSelector.tsx | 99 +++++++ .../checkout/SubmitCheckoutButton.tsx | 27 ++ .../(checkout)/checkout/checkout-provider.tsx | 140 +++++++++ .../src/app/(checkout)/checkout/page.tsx | 47 +++ .../checkout/usePaymentComplete.tsx | 175 +++++++++++ .../(checkout)/checkout/useShippingMethod.tsx | 39 +++ .../payments/src/app/(checkout)/layout.tsx | 51 ++++ .../payments/src/app/(checkout)/not-found.tsx | 13 + .../src/app/(store)}/about/page.tsx | 2 +- .../app/(store)/account/AccountNavigation.tsx | 54 ++++ .../account/addresses/[addressId]/page.tsx | 236 +++++++++++++++ .../app/(store)/account/addresses/actions.ts | 200 +++++++++++++ .../(store)/account/addresses/add/page.tsx | 209 +++++++++++++ .../app/(store)/account/addresses/page.tsx | 107 +++++++ .../src/app/(store)/account/breadcrumb.tsx | 85 ++++++ .../src/app/(store)/account/layout.tsx | 17 ++ .../app/(store)/account/orders/OrderItem.tsx | 70 +++++ .../account/orders/OrderItemWithDetails.tsx | 32 ++ .../orders/[orderId]/OrderLineItem.tsx | 42 +++ .../orders/[orderId]/ProductThumbnail.tsx | 22 ++ .../(store)/account/orders/[orderId]/page.tsx | 206 +++++++++++++ .../src/app/(store)/account/orders/page.tsx | 128 ++++++++ .../(store)/account/summary/YourInfoForm.tsx | 59 ++++ .../app/(store)/account/summary/actions.ts | 220 ++++++++++++++ .../src/app/(store)/account/summary/page.tsx | 113 +++++++ .../src/app/(store)/cart/CartItem.tsx | 62 ++++ .../src/app/(store)/cart/CartItemWide.tsx | 62 ++++ .../src/app/(store)/cart/CartSidebar.tsx | 45 +++ .../src/app/(store)/cart/CartView.tsx | 52 ++++ .../payments/src/app/(store)/cart/YourBag.tsx | 23 ++ .../payments/src/app/(store)/cart/page.tsx | 5 + .../src/app/{ => (store)}/faq/page.tsx | 2 +- examples/payments/src/app/(store)/layout.tsx | 64 ++++ .../payments/src/app/(store)/not-found.tsx | 13 + .../payments/src/app/{ => (store)}/page.tsx | 4 +- .../products/[productId]/page.tsx | 11 +- .../products/[productId]/product-display.tsx | 30 +- .../(store)}/search/[[...node]]/layout.tsx | 2 +- .../{ => (store)}/search/[[...node]]/page.tsx | 4 +- .../src/app/{ => (store)}/search/search.tsx | 2 +- .../src/app/(store)}/shipping/page.tsx | 2 +- .../src/app/(store)}/terms/page.tsx | 2 +- examples/payments/src/app/cart/page.tsx | 42 --- .../app/checkout/[cartId]/cart-display.tsx | 75 ----- .../src/app/checkout/[cartId]/page.tsx | 28 -- examples/payments/src/app/layout.tsx | 53 +--- examples/payments/src/app/providers.tsx | 46 ++- examples/payments/src/components/Checkbox.tsx | 29 ++ .../payments/src/components/LoadingDots.tsx | 13 + .../src/components/accordion/Accordion.tsx | 51 ++++ .../payments/src/components/alert/Alert.tsx | 59 ++++ .../payments/src/components/button/Button.tsx | 68 +++++ .../components/button/FormStatusButton.tsx | 41 +++ .../src/components/button/LoaderIcon.tsx | 17 ++ .../src/components/button/StatusButton.tsx | 47 +++ .../src/components/button/TextButton.tsx | 41 +++ .../payments/src/components/cart/Cart.tsx | 38 --- .../src/components/cart/CartDiscounts.tsx | 94 ++++++ .../src/components/cart/CartItemList.tsx | 65 ---- .../src/components/cart/CartOrderSummary.tsx | 80 ----- .../src/components/cart/CartSheet.tsx | 166 +++++++++++ .../src/components/cart/Promotion.tsx | 59 ---- .../components/checkout-item/CheckoutItem.tsx | 32 ++ .../checkout-sidebar/AddPromotion.tsx | 73 +++++ .../checkout-sidebar/ItemSidebar.tsx | 166 +++++++++++ .../components/checkout-sidebar/actions.ts | 43 +++ .../src/components/checkout/BillingForm.tsx | 93 ------ .../src/components/checkout/CountrySelect.tsx | 262 ----------------- .../components/checkout/CustomFormControl.tsx | 46 --- .../components/checkout/CustomFormSelect.tsx | 50 ---- .../src/components/checkout/OrderComplete.tsx | 173 ----------- .../src/components/checkout/OrderSummary.tsx | 106 ------- .../src/components/checkout/ShippingForm.tsx | 109 ------- .../form-schema/checkout-form-schema.ts | 64 +++- .../checkout/payments/CheckoutForm.tsx | 196 ------------- .../payments/EpStripePayment.module.css | 69 ----- .../checkout/payments/EpStripePayment.tsx | 42 --- .../checkout/payments/EpStripePaymentForm.tsx | 96 ------ .../checkout/types/checkout-form.ts | 11 - .../checkout/types/order-pending-state.ts | 9 - .../payments/src/components/form/Form.tsx | 183 ++++++++++++ .../components/header/AccountMobileMenu.tsx | 159 ++++++++++ .../payments/src/components/header/Header.tsx | 9 +- .../components/header/account/AccountMenu.tsx | 194 ++++++++++++ .../header/account/AccountSwitcher.tsx | 52 ++++ .../header/account/switch-button.tsx | 57 ++++ .../src/components/header/cart/CartMenu.tsx | 115 -------- .../components/header/cart/ModalCartItem.tsx | 168 ----------- .../navigation/MobileAccountSwitcher.tsx | 70 +++++ .../header/navigation/MobileNavBar.tsx | 4 +- .../header/navigation/MobileNavBarButton.tsx | 75 +++-- .../header/navigation/NavBarPopover.tsx | 102 +++++-- .../header/navigation/NavItemContent.tsx | 16 +- .../components/header/navigation/NavMenu.tsx | 133 +++------ .../payments/src/components/input/Input.tsx | 41 +++ .../payments/src/components/label/Label.tsx | 18 ++ .../navigation-menu/NavigationMenu.tsx | 122 ++++++++ .../number-input/EditQuantityButton.tsx | 55 ++++ .../components/number-input/NumberInput.tsx | 50 ++++ .../components/number-input/use-counter.ts | 277 ++++++++++++++++++ .../src/components/pagination/Pagination.tsx | 120 ++++++++ .../pagination/ResourcePagination.tsx | 114 +++++++ .../src/components/product/CartActions.tsx | 35 --- .../components/product/ProductContainer.tsx | 42 --- .../src/components/product/ProductSummary.tsx | 1 - .../src/components/product/SimpleProduct.tsx | 48 ++- .../product/bundles/BundleProduct.tsx | 55 +++- .../product/bundles/ProductComponent.tsx | 1 + .../product/variations/VariationProduct.tsx | 52 ++-- .../quantity-handler/QuantityHandler.tsx | 44 --- .../src/components/radio-group/RadioGroup.tsx | 43 +++ .../src/components/search/MobileFilters.tsx | 2 +- .../src/components/search/Pagination.tsx | 2 +- .../payments/src/components/select/Select.tsx | 183 ++++++++++++ .../src/components/separator/Separator.tsx | 29 ++ .../payments/src/components/sheet/Sheet.tsx | 134 +++++++++ .../src/components/skeleton/Skeleton.tsx | 15 + examples/payments/src/hooks/use-countries.tsx | 18 ++ examples/payments/src/lib/all-countries.ts | 258 ++++++++++++++++ .../payments/src/lib/cart-cookie-client.ts | 17 -- examples/payments/src/lib/cn.tsx | 6 + examples/payments/src/lib/constants.ts | 6 + examples/payments/src/lib/cookie-constants.ts | 1 + examples/payments/src/lib/ep-client-store.ts | 27 -- .../payments/src/lib/epcc-server-client.ts | 2 +- .../epcc-server-side-credentials-client.ts | 49 ++++ examples/payments/src/lib/format-currency.tsx | 20 ++ .../src/lib/format-iso-date-string.ts | 8 + .../payments/src/lib/get-error-message.ts | 15 + ...-context.ts => get-store-initial-state.ts} | 7 +- .../lib/is-account-member-authenticated.ts | 13 + examples/payments/src/lib/parse-cookie.ts | 32 -- .../src/lib/providers/store-provider.tsx | 19 -- .../src/lib/resolve-shopping-cart-props.ts | 29 -- .../retrieve-account-member-credentials.ts | 49 ++++ .../payments/src/lib/types/store-context.ts | 17 -- examples/payments/src/services/cart.ts | 13 - examples/payments/src/services/checkout.ts | 34 --- examples/payments/src/styles/globals.css | 37 --- examples/payments/tailwind.config.ts | 38 ++- examples/payments/tsconfig.json | 3 +- examples/simple/README.md | 2 +- examples/simple/package.json | 25 +- .../account-memeber-credentials-schema.ts | 23 ++ examples/simple/src/app/(auth)/actions.ts | 200 +++++++++++++ examples/simple/src/app/(auth)/layout.tsx | 51 ++++ .../simple/src/app/(auth)/login/LoginForm.tsx | 78 +++++ examples/simple/src/app/(auth)/login/page.tsx | 49 ++++ examples/simple/src/app/(auth)/not-found.tsx | 13 + .../simple/src/app/(auth)/register/page.tsx | 97 ++++++ .../(checkout)/checkout/AccountCheckout.tsx | 52 ++++ .../(checkout)/checkout/AccountDisplay.tsx | 82 ++++++ .../app/(checkout)/checkout/BillingForm.tsx | 233 +++++++++++++++ .../(checkout)/checkout/CheckoutFooter.tsx | 30 ++ .../(checkout)/checkout/CheckoutSidebar.tsx | 102 +++++++ .../app/(checkout)/checkout/CheckoutViews.tsx | 15 + .../checkout/ConfirmationSidebar.tsx | 108 +++++++ .../app/(checkout)/checkout/DeliveryForm.tsx | 108 +++++++ .../src/app/(checkout)/checkout/FormInput.tsx | 73 +++++ .../app/(checkout)/checkout/GuestCheckout.tsx | 51 ++++ .../(checkout)/checkout/GuestInformation.tsx | 56 ++++ .../(checkout)/checkout/OrderConfirmation.tsx | 87 ++++++ .../app/(checkout)/checkout/PaymentForm.tsx | 30 ++ .../app/(checkout)/checkout/ShippingForm.tsx | 217 ++++++++++++++ .../(checkout)/checkout/ShippingSelector.tsx | 99 +++++++ .../checkout/SubmitCheckoutButton.tsx | 27 ++ .../(checkout)/checkout/checkout-provider.tsx | 103 +++++++ .../src/app/(checkout)/checkout/page.tsx | 47 +++ .../checkout/usePaymentComplete.tsx | 129 ++++++++ .../(checkout)/checkout/useShippingMethod.tsx | 39 +++ examples/simple/src/app/(checkout)/layout.tsx | 51 ++++ .../simple/src/app/(checkout)/not-found.tsx | 13 + .../src/app/(store)}/about/page.tsx | 2 +- .../app/(store)/account/AccountNavigation.tsx | 54 ++++ .../account/addresses/[addressId]/page.tsx | 236 +++++++++++++++ .../app/(store)/account/addresses/actions.ts | 200 +++++++++++++ .../(store)/account/addresses/add/page.tsx | 209 +++++++++++++ .../app/(store)/account/addresses/page.tsx | 107 +++++++ .../src/app/(store)/account/breadcrumb.tsx | 85 ++++++ .../simple/src/app/(store)/account/layout.tsx | 17 ++ .../app/(store)/account/orders/OrderItem.tsx | 70 +++++ .../account/orders/OrderItemWithDetails.tsx | 32 ++ .../orders/[orderId]/OrderLineItem.tsx | 42 +++ .../orders/[orderId]/ProductThumbnail.tsx | 22 ++ .../(store)/account/orders/[orderId]/page.tsx | 206 +++++++++++++ .../src/app/(store)/account/orders/page.tsx | 128 ++++++++ .../(store)/account/summary/YourInfoForm.tsx | 59 ++++ .../app/(store)/account/summary/actions.ts | 220 ++++++++++++++ .../src/app/(store)/account/summary/page.tsx | 113 +++++++ .../simple/src/app/(store)/cart/CartItem.tsx | 62 ++++ .../src/app/(store)/cart/CartItemWide.tsx | 62 ++++ .../src/app/(store)/cart/CartSidebar.tsx | 45 +++ .../simple/src/app/(store)/cart/CartView.tsx | 52 ++++ .../simple/src/app/(store)/cart/YourBag.tsx | 23 ++ examples/simple/src/app/(store)/cart/page.tsx | 5 + .../src/app/(store)}/faq/page.tsx | 2 +- examples/simple/src/app/(store)/layout.tsx | 64 ++++ examples/simple/src/app/(store)/not-found.tsx | 13 + .../app => simple/src/app/(store)}/page.tsx | 4 +- .../products/[productId]/page.tsx | 11 +- .../products/[productId]/product-display.tsx | 30 +- .../(store)}/search/[[...node]]/layout.tsx | 2 +- .../{ => (store)}/search/[[...node]]/page.tsx | 4 +- .../src/app/{ => (store)}/search/search.tsx | 2 +- .../src/app/{ => (store)}/shipping/page.tsx | 2 +- .../src/app/(store)}/terms/page.tsx | 2 +- examples/simple/src/app/cart/page.tsx | 42 --- .../app/checkout/[cartId]/cart-display.tsx | 75 ----- .../simple/src/app/checkout/[cartId]/page.tsx | 28 -- examples/simple/src/app/layout.tsx | 53 +--- examples/simple/src/app/providers.tsx | 46 ++- examples/simple/src/components/Checkbox.tsx | 29 ++ .../simple/src/components/LoadingDots.tsx | 13 + .../src/components/accordion/Accordion.tsx | 51 ++++ .../simple/src/components/alert/Alert.tsx | 59 ++++ .../simple/src/components/button/Button.tsx | 68 +++++ .../components/button/FormStatusButton.tsx | 41 +++ .../src/components/button/LoaderIcon.tsx | 17 ++ .../src/components/button/StatusButton.tsx | 47 +++ .../src/components/button/TextButton.tsx | 41 +++ examples/simple/src/components/cart/Cart.tsx | 38 --- .../src/components/cart/CartDiscounts.tsx | 94 ++++++ .../src/components/cart/CartItemList.tsx | 65 ---- .../src/components/cart/CartOrderSummary.tsx | 80 ----- .../simple/src/components/cart/CartSheet.tsx | 166 +++++++++++ .../simple/src/components/cart/Promotion.tsx | 59 ---- .../components/checkout-item/CheckoutItem.tsx | 32 ++ .../checkout-sidebar/AddPromotion.tsx | 73 +++++ .../checkout-sidebar/ItemSidebar.tsx | 166 +++++++++++ .../components/checkout-sidebar/actions.ts | 43 +++ .../src/components/checkout/BillingForm.tsx | 93 ------ .../src/components/checkout/CountrySelect.tsx | 262 ----------------- .../components/checkout/CustomFormControl.tsx | 46 --- .../components/checkout/CustomFormSelect.tsx | 50 ---- .../src/components/checkout/OrderComplete.tsx | 173 ----------- .../src/components/checkout/OrderSummary.tsx | 106 ------- .../src/components/checkout/ShippingForm.tsx | 109 ------- .../form-schema/checkout-form-schema.ts | 64 +++- .../checkout/payments/CheckoutForm.tsx | 137 --------- .../checkout/types/checkout-form.ts | 11 - .../checkout/types/order-pending-state.ts | 9 - examples/simple/src/components/form/Form.tsx | 183 ++++++++++++ .../components/header/AccountMobileMenu.tsx | 159 ++++++++++ .../simple/src/components/header/Header.tsx | 9 +- .../components/header/account/AccountMenu.tsx | 194 ++++++++++++ .../header/account/AccountSwitcher.tsx | 52 ++++ .../header/account/switch-button.tsx | 57 ++++ .../src/components/header/cart/CartMenu.tsx | 115 -------- .../components/header/cart/ModalCartItem.tsx | 168 ----------- .../navigation/MobileAccountSwitcher.tsx | 70 +++++ .../header/navigation/MobileNavBar.tsx | 4 +- .../header/navigation/MobileNavBarButton.tsx | 75 +++-- .../header/navigation/NavBarPopover.tsx | 102 +++++-- .../header/navigation/NavItemContent.tsx | 16 +- .../components/header/navigation/NavMenu.tsx | 133 +++------ .../simple/src/components/input/Input.tsx | 41 +++ .../simple/src/components/label/Label.tsx | 18 ++ .../navigation-menu/NavigationMenu.tsx | 122 ++++++++ .../number-input/EditQuantityButton.tsx | 55 ++++ .../components/number-input/NumberInput.tsx | 50 ++++ .../components/number-input/use-counter.ts | 277 ++++++++++++++++++ .../src/components/pagination/Pagination.tsx | 120 ++++++++ .../pagination/ResourcePagination.tsx | 114 +++++++ .../src/components/product/CartActions.tsx | 35 --- .../components/product/ProductContainer.tsx | 42 --- .../src/components/product/ProductSummary.tsx | 1 - .../src/components/product/SimpleProduct.tsx | 48 ++- .../product/bundles/BundleProduct.tsx | 55 +++- .../product/bundles/ProductComponent.tsx | 1 + .../product/variations/VariationProduct.tsx | 52 ++-- .../quantity-handler/QuantityHandler.tsx | 44 --- .../src/components/radio-group/RadioGroup.tsx | 43 +++ .../src/components/search/MobileFilters.tsx | 2 +- .../src/components/search/Pagination.tsx | 2 +- .../simple/src/components/select/Select.tsx | 183 ++++++++++++ .../src/components/separator/Separator.tsx | 29 ++ .../simple/src/components/sheet/Sheet.tsx | 134 +++++++++ .../src/components/skeleton/Skeleton.tsx | 15 + examples/simple/src/hooks/use-countries.tsx | 18 ++ examples/simple/src/lib/all-countries.ts | 258 ++++++++++++++++ examples/simple/src/lib/cart-cookie-client.ts | 17 -- examples/simple/src/lib/cn.tsx | 6 + examples/simple/src/lib/constants.ts | 6 + examples/simple/src/lib/cookie-constants.ts | 1 + examples/simple/src/lib/ep-client-store.ts | 27 -- examples/simple/src/lib/epcc-server-client.ts | 2 +- .../epcc-server-side-credentials-client.ts | 49 ++++ examples/simple/src/lib/format-currency.tsx | 20 ++ .../simple/src/lib/format-iso-date-string.ts | 8 + examples/simple/src/lib/get-error-message.ts | 15 + ...-context.ts => get-store-initial-state.ts} | 7 +- .../lib/is-account-member-authenticated.ts | 13 + examples/simple/src/lib/parse-cookie.ts | 32 -- .../src/lib/providers/store-provider.tsx | 19 -- .../src/lib/resolve-shopping-cart-props.ts | 29 -- .../retrieve-account-member-credentials.ts | 49 ++++ .../simple/src/lib/types/store-context.ts | 17 -- examples/simple/src/services/cart.ts | 13 - examples/simple/src/services/checkout.ts | 34 --- examples/simple/src/styles/globals.css | 37 --- examples/simple/tailwind.config.ts | 38 ++- examples/simple/tsconfig.json | 3 +- 479 files changed, 24771 insertions(+), 6340 deletions(-) create mode 100644 examples/algolia/e2e/checkout-flow.spec.ts create mode 100644 examples/algolia/e2e/models/d2c-checkout-page.ts create mode 100644 examples/algolia/e2e/util/enter-payment-information.ts create mode 100644 examples/algolia/e2e/util/gateway-check.ts create mode 100644 examples/algolia/e2e/util/gateway-is-enabled.ts create mode 100644 examples/algolia/src/app/(auth)/account-memeber-credentials-schema.ts create mode 100644 examples/algolia/src/app/(auth)/actions.ts create mode 100644 examples/algolia/src/app/(auth)/layout.tsx create mode 100644 examples/algolia/src/app/(auth)/login/LoginForm.tsx create mode 100644 examples/algolia/src/app/(auth)/login/page.tsx create mode 100644 examples/algolia/src/app/(auth)/not-found.tsx create mode 100644 examples/algolia/src/app/(auth)/register/page.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/AccountCheckout.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/AccountDisplay.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/BillingForm.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/CheckoutFooter.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/CheckoutSidebar.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/CheckoutViews.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/ConfirmationSidebar.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/DeliveryForm.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/FormInput.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/GuestCheckout.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/GuestInformation.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/OrderConfirmation.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/PaymentForm.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/ShippingForm.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/ShippingSelector.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/SubmitCheckoutButton.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/checkout-provider.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/page.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/usePaymentComplete.tsx create mode 100644 examples/algolia/src/app/(checkout)/checkout/useShippingMethod.tsx create mode 100644 examples/algolia/src/app/(checkout)/layout.tsx create mode 100644 examples/algolia/src/app/(checkout)/not-found.tsx rename examples/algolia/src/app/{ => (store)}/about/page.tsx (56%) create mode 100644 examples/algolia/src/app/(store)/account/AccountNavigation.tsx create mode 100644 examples/algolia/src/app/(store)/account/addresses/[addressId]/page.tsx create mode 100644 examples/algolia/src/app/(store)/account/addresses/actions.ts create mode 100644 examples/algolia/src/app/(store)/account/addresses/add/page.tsx create mode 100644 examples/algolia/src/app/(store)/account/addresses/page.tsx create mode 100644 examples/algolia/src/app/(store)/account/breadcrumb.tsx create mode 100644 examples/algolia/src/app/(store)/account/layout.tsx create mode 100644 examples/algolia/src/app/(store)/account/orders/OrderItem.tsx create mode 100644 examples/algolia/src/app/(store)/account/orders/OrderItemWithDetails.tsx create mode 100644 examples/algolia/src/app/(store)/account/orders/[orderId]/OrderLineItem.tsx create mode 100644 examples/algolia/src/app/(store)/account/orders/[orderId]/ProductThumbnail.tsx create mode 100644 examples/algolia/src/app/(store)/account/orders/[orderId]/page.tsx create mode 100644 examples/algolia/src/app/(store)/account/orders/page.tsx create mode 100644 examples/algolia/src/app/(store)/account/summary/YourInfoForm.tsx create mode 100644 examples/algolia/src/app/(store)/account/summary/actions.ts create mode 100644 examples/algolia/src/app/(store)/account/summary/page.tsx create mode 100644 examples/algolia/src/app/(store)/cart/CartItem.tsx create mode 100644 examples/algolia/src/app/(store)/cart/CartItemWide.tsx create mode 100644 examples/algolia/src/app/(store)/cart/CartSidebar.tsx create mode 100644 examples/algolia/src/app/(store)/cart/CartView.tsx create mode 100644 examples/algolia/src/app/(store)/cart/YourBag.tsx create mode 100644 examples/algolia/src/app/(store)/cart/page.tsx rename examples/{simple/src/app => algolia/src/app/(store)}/faq/page.tsx (55%) create mode 100644 examples/algolia/src/app/(store)/layout.tsx create mode 100644 examples/algolia/src/app/(store)/not-found.tsx rename examples/{simple/src/app => algolia/src/app/(store)}/page.tsx (84%) rename examples/algolia/src/app/{ => (store)}/products/[productId]/page.tsx (73%) rename examples/{payments/src/app => algolia/src/app/(store)}/products/[productId]/product-display.tsx (52%) rename examples/algolia/src/app/{ => (store)}/search/[[...node]]/layout.tsx (79%) rename examples/algolia/src/app/{ => (store)}/search/[[...node]]/page.tsx (100%) rename examples/algolia/src/app/{ => (store)}/search/search.tsx (79%) rename examples/{payments/src/app => algolia/src/app/(store)}/shipping/page.tsx (58%) rename examples/{payments/src/app => algolia/src/app/(store)}/terms/page.tsx (60%) delete mode 100644 examples/algolia/src/app/cart/page.tsx create mode 100644 examples/algolia/src/components/Checkbox.tsx create mode 100644 examples/algolia/src/components/LoadingDots.tsx create mode 100644 examples/algolia/src/components/accordion/Accordion.tsx create mode 100644 examples/algolia/src/components/alert/Alert.tsx create mode 100644 examples/algolia/src/components/button/Button.tsx create mode 100644 examples/algolia/src/components/button/FormStatusButton.tsx create mode 100644 examples/algolia/src/components/button/LoaderIcon.tsx create mode 100644 examples/algolia/src/components/button/StatusButton.tsx create mode 100644 examples/algolia/src/components/button/TextButton.tsx delete mode 100644 examples/algolia/src/components/cart/Cart.tsx create mode 100644 examples/algolia/src/components/cart/CartDiscounts.tsx delete mode 100644 examples/algolia/src/components/cart/CartItemList.tsx delete mode 100644 examples/algolia/src/components/cart/CartOrderSummary.tsx create mode 100644 examples/algolia/src/components/cart/CartSheet.tsx delete mode 100644 examples/algolia/src/components/cart/Promotion.tsx create mode 100644 examples/algolia/src/components/checkout-item/CheckoutItem.tsx create mode 100644 examples/algolia/src/components/checkout-sidebar/AddPromotion.tsx create mode 100644 examples/algolia/src/components/checkout-sidebar/ItemSidebar.tsx create mode 100644 examples/algolia/src/components/checkout-sidebar/actions.ts create mode 100644 examples/algolia/src/components/checkout/form-schema/checkout-form-schema.ts create mode 100644 examples/algolia/src/components/form/Form.tsx create mode 100644 examples/algolia/src/components/header/AccountMobileMenu.tsx create mode 100644 examples/algolia/src/components/header/account/AccountMenu.tsx create mode 100644 examples/algolia/src/components/header/account/AccountSwitcher.tsx create mode 100644 examples/algolia/src/components/header/account/switch-button.tsx delete mode 100644 examples/algolia/src/components/header/cart/CartMenu.tsx delete mode 100644 examples/algolia/src/components/header/cart/ModalCartItem.tsx create mode 100644 examples/algolia/src/components/header/navigation/MobileAccountSwitcher.tsx create mode 100644 examples/algolia/src/components/input/Input.tsx create mode 100644 examples/algolia/src/components/label/Label.tsx create mode 100644 examples/algolia/src/components/navigation-menu/NavigationMenu.tsx create mode 100644 examples/algolia/src/components/number-input/EditQuantityButton.tsx create mode 100644 examples/algolia/src/components/number-input/NumberInput.tsx create mode 100644 examples/algolia/src/components/number-input/use-counter.ts create mode 100644 examples/algolia/src/components/pagination/Pagination.tsx create mode 100644 examples/algolia/src/components/pagination/ResourcePagination.tsx delete mode 100644 examples/algolia/src/components/product/CartActions.tsx delete mode 100644 examples/algolia/src/components/product/ProductContainer.tsx delete mode 100644 examples/algolia/src/components/quantity-handler/QuantityHandler.tsx create mode 100644 examples/algolia/src/components/radio-group/RadioGroup.tsx delete mode 100644 examples/algolia/src/components/search/SearchModal.tsx create mode 100644 examples/algolia/src/components/select/Select.tsx create mode 100644 examples/algolia/src/components/separator/Separator.tsx create mode 100644 examples/algolia/src/components/sheet/Sheet.tsx create mode 100644 examples/algolia/src/components/skeleton/Skeleton.tsx create mode 100644 examples/algolia/src/hooks/use-countries.tsx create mode 100644 examples/algolia/src/lib/all-countries.ts delete mode 100644 examples/algolia/src/lib/cart-cookie-client.ts create mode 100644 examples/algolia/src/lib/cn.tsx create mode 100644 examples/algolia/src/lib/constants.ts delete mode 100644 examples/algolia/src/lib/ep-client-store.ts create mode 100644 examples/algolia/src/lib/epcc-server-side-credentials-client.ts create mode 100644 examples/algolia/src/lib/format-currency.tsx create mode 100644 examples/algolia/src/lib/format-iso-date-string.ts create mode 100644 examples/algolia/src/lib/get-error-message.ts rename examples/algolia/src/lib/{get-store-context.ts => get-store-initial-state.ts} (69%) create mode 100644 examples/algolia/src/lib/is-account-member-authenticated.ts delete mode 100644 examples/algolia/src/lib/parse-cookie.ts delete mode 100644 examples/algolia/src/lib/providers/store-provider.tsx delete mode 100644 examples/algolia/src/lib/resolve-shopping-cart-props.ts create mode 100644 examples/algolia/src/lib/retrieve-account-member-credentials.ts delete mode 100644 examples/algolia/src/lib/types/store-context.ts create mode 100644 examples/payments/src/app/(auth)/account-memeber-credentials-schema.ts create mode 100644 examples/payments/src/app/(auth)/actions.ts create mode 100644 examples/payments/src/app/(auth)/layout.tsx create mode 100644 examples/payments/src/app/(auth)/login/LoginForm.tsx create mode 100644 examples/payments/src/app/(auth)/login/page.tsx create mode 100644 examples/payments/src/app/(auth)/not-found.tsx create mode 100644 examples/payments/src/app/(auth)/register/page.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/AccountCheckout.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/AccountDisplay.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/BillingForm.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/CheckoutFooter.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/CheckoutSidebar.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/CheckoutViews.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/ConfirmationSidebar.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/DeliveryForm.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/FormInput.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/GuestCheckout.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/GuestInformation.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/OrderConfirmation.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/PaymentForm.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/ShippingForm.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/ShippingSelector.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/SubmitCheckoutButton.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/checkout-provider.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/page.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/usePaymentComplete.tsx create mode 100644 examples/payments/src/app/(checkout)/checkout/useShippingMethod.tsx create mode 100644 examples/payments/src/app/(checkout)/layout.tsx create mode 100644 examples/payments/src/app/(checkout)/not-found.tsx rename examples/{simple/src/app => payments/src/app/(store)}/about/page.tsx (56%) create mode 100644 examples/payments/src/app/(store)/account/AccountNavigation.tsx create mode 100644 examples/payments/src/app/(store)/account/addresses/[addressId]/page.tsx create mode 100644 examples/payments/src/app/(store)/account/addresses/actions.ts create mode 100644 examples/payments/src/app/(store)/account/addresses/add/page.tsx create mode 100644 examples/payments/src/app/(store)/account/addresses/page.tsx create mode 100644 examples/payments/src/app/(store)/account/breadcrumb.tsx create mode 100644 examples/payments/src/app/(store)/account/layout.tsx create mode 100644 examples/payments/src/app/(store)/account/orders/OrderItem.tsx create mode 100644 examples/payments/src/app/(store)/account/orders/OrderItemWithDetails.tsx create mode 100644 examples/payments/src/app/(store)/account/orders/[orderId]/OrderLineItem.tsx create mode 100644 examples/payments/src/app/(store)/account/orders/[orderId]/ProductThumbnail.tsx create mode 100644 examples/payments/src/app/(store)/account/orders/[orderId]/page.tsx create mode 100644 examples/payments/src/app/(store)/account/orders/page.tsx create mode 100644 examples/payments/src/app/(store)/account/summary/YourInfoForm.tsx create mode 100644 examples/payments/src/app/(store)/account/summary/actions.ts create mode 100644 examples/payments/src/app/(store)/account/summary/page.tsx create mode 100644 examples/payments/src/app/(store)/cart/CartItem.tsx create mode 100644 examples/payments/src/app/(store)/cart/CartItemWide.tsx create mode 100644 examples/payments/src/app/(store)/cart/CartSidebar.tsx create mode 100644 examples/payments/src/app/(store)/cart/CartView.tsx create mode 100644 examples/payments/src/app/(store)/cart/YourBag.tsx create mode 100644 examples/payments/src/app/(store)/cart/page.tsx rename examples/payments/src/app/{ => (store)}/faq/page.tsx (55%) create mode 100644 examples/payments/src/app/(store)/layout.tsx create mode 100644 examples/payments/src/app/(store)/not-found.tsx rename examples/payments/src/app/{ => (store)}/page.tsx (84%) rename examples/payments/src/app/{ => (store)}/products/[productId]/page.tsx (73%) rename examples/{simple/src/app => payments/src/app/(store)}/products/[productId]/product-display.tsx (52%) rename examples/{simple/src/app => payments/src/app/(store)}/search/[[...node]]/layout.tsx (79%) rename examples/payments/src/app/{ => (store)}/search/[[...node]]/page.tsx (97%) rename examples/payments/src/app/{ => (store)}/search/search.tsx (86%) rename examples/{algolia/src/app => payments/src/app/(store)}/shipping/page.tsx (58%) rename examples/{simple/src/app => payments/src/app/(store)}/terms/page.tsx (60%) delete mode 100644 examples/payments/src/app/cart/page.tsx delete mode 100644 examples/payments/src/app/checkout/[cartId]/cart-display.tsx delete mode 100644 examples/payments/src/app/checkout/[cartId]/page.tsx create mode 100644 examples/payments/src/components/Checkbox.tsx create mode 100644 examples/payments/src/components/LoadingDots.tsx create mode 100644 examples/payments/src/components/accordion/Accordion.tsx create mode 100644 examples/payments/src/components/alert/Alert.tsx create mode 100644 examples/payments/src/components/button/Button.tsx create mode 100644 examples/payments/src/components/button/FormStatusButton.tsx create mode 100644 examples/payments/src/components/button/LoaderIcon.tsx create mode 100644 examples/payments/src/components/button/StatusButton.tsx create mode 100644 examples/payments/src/components/button/TextButton.tsx delete mode 100644 examples/payments/src/components/cart/Cart.tsx create mode 100644 examples/payments/src/components/cart/CartDiscounts.tsx delete mode 100644 examples/payments/src/components/cart/CartItemList.tsx delete mode 100644 examples/payments/src/components/cart/CartOrderSummary.tsx create mode 100644 examples/payments/src/components/cart/CartSheet.tsx delete mode 100644 examples/payments/src/components/cart/Promotion.tsx create mode 100644 examples/payments/src/components/checkout-item/CheckoutItem.tsx create mode 100644 examples/payments/src/components/checkout-sidebar/AddPromotion.tsx create mode 100644 examples/payments/src/components/checkout-sidebar/ItemSidebar.tsx create mode 100644 examples/payments/src/components/checkout-sidebar/actions.ts delete mode 100644 examples/payments/src/components/checkout/BillingForm.tsx delete mode 100644 examples/payments/src/components/checkout/CountrySelect.tsx delete mode 100644 examples/payments/src/components/checkout/CustomFormControl.tsx delete mode 100644 examples/payments/src/components/checkout/CustomFormSelect.tsx delete mode 100644 examples/payments/src/components/checkout/OrderComplete.tsx delete mode 100644 examples/payments/src/components/checkout/OrderSummary.tsx delete mode 100644 examples/payments/src/components/checkout/ShippingForm.tsx delete mode 100644 examples/payments/src/components/checkout/payments/CheckoutForm.tsx delete mode 100644 examples/payments/src/components/checkout/payments/EpStripePayment.module.css delete mode 100644 examples/payments/src/components/checkout/payments/EpStripePayment.tsx delete mode 100644 examples/payments/src/components/checkout/payments/EpStripePaymentForm.tsx delete mode 100644 examples/payments/src/components/checkout/types/checkout-form.ts delete mode 100644 examples/payments/src/components/checkout/types/order-pending-state.ts create mode 100644 examples/payments/src/components/form/Form.tsx create mode 100644 examples/payments/src/components/header/AccountMobileMenu.tsx create mode 100644 examples/payments/src/components/header/account/AccountMenu.tsx create mode 100644 examples/payments/src/components/header/account/AccountSwitcher.tsx create mode 100644 examples/payments/src/components/header/account/switch-button.tsx delete mode 100644 examples/payments/src/components/header/cart/CartMenu.tsx delete mode 100644 examples/payments/src/components/header/cart/ModalCartItem.tsx create mode 100644 examples/payments/src/components/header/navigation/MobileAccountSwitcher.tsx create mode 100644 examples/payments/src/components/input/Input.tsx create mode 100644 examples/payments/src/components/label/Label.tsx create mode 100644 examples/payments/src/components/navigation-menu/NavigationMenu.tsx create mode 100644 examples/payments/src/components/number-input/EditQuantityButton.tsx create mode 100644 examples/payments/src/components/number-input/NumberInput.tsx create mode 100644 examples/payments/src/components/number-input/use-counter.ts create mode 100644 examples/payments/src/components/pagination/Pagination.tsx create mode 100644 examples/payments/src/components/pagination/ResourcePagination.tsx delete mode 100644 examples/payments/src/components/product/CartActions.tsx delete mode 100644 examples/payments/src/components/product/ProductContainer.tsx delete mode 100644 examples/payments/src/components/quantity-handler/QuantityHandler.tsx create mode 100644 examples/payments/src/components/radio-group/RadioGroup.tsx create mode 100644 examples/payments/src/components/select/Select.tsx create mode 100644 examples/payments/src/components/separator/Separator.tsx create mode 100644 examples/payments/src/components/sheet/Sheet.tsx create mode 100644 examples/payments/src/components/skeleton/Skeleton.tsx create mode 100644 examples/payments/src/hooks/use-countries.tsx create mode 100644 examples/payments/src/lib/all-countries.ts delete mode 100644 examples/payments/src/lib/cart-cookie-client.ts create mode 100644 examples/payments/src/lib/cn.tsx create mode 100644 examples/payments/src/lib/constants.ts delete mode 100644 examples/payments/src/lib/ep-client-store.ts create mode 100644 examples/payments/src/lib/epcc-server-side-credentials-client.ts create mode 100644 examples/payments/src/lib/format-currency.tsx create mode 100644 examples/payments/src/lib/format-iso-date-string.ts create mode 100644 examples/payments/src/lib/get-error-message.ts rename examples/payments/src/lib/{get-store-context.ts => get-store-initial-state.ts} (69%) create mode 100644 examples/payments/src/lib/is-account-member-authenticated.ts delete mode 100644 examples/payments/src/lib/parse-cookie.ts delete mode 100644 examples/payments/src/lib/providers/store-provider.tsx delete mode 100644 examples/payments/src/lib/resolve-shopping-cart-props.ts create mode 100644 examples/payments/src/lib/retrieve-account-member-credentials.ts delete mode 100644 examples/payments/src/lib/types/store-context.ts delete mode 100644 examples/payments/src/services/checkout.ts create mode 100644 examples/simple/src/app/(auth)/account-memeber-credentials-schema.ts create mode 100644 examples/simple/src/app/(auth)/actions.ts create mode 100644 examples/simple/src/app/(auth)/layout.tsx create mode 100644 examples/simple/src/app/(auth)/login/LoginForm.tsx create mode 100644 examples/simple/src/app/(auth)/login/page.tsx create mode 100644 examples/simple/src/app/(auth)/not-found.tsx create mode 100644 examples/simple/src/app/(auth)/register/page.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/AccountCheckout.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/AccountDisplay.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/BillingForm.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/CheckoutFooter.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/CheckoutSidebar.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/CheckoutViews.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/ConfirmationSidebar.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/DeliveryForm.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/FormInput.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/GuestCheckout.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/GuestInformation.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/OrderConfirmation.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/PaymentForm.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/ShippingForm.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/ShippingSelector.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/SubmitCheckoutButton.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/checkout-provider.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/page.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/usePaymentComplete.tsx create mode 100644 examples/simple/src/app/(checkout)/checkout/useShippingMethod.tsx create mode 100644 examples/simple/src/app/(checkout)/layout.tsx create mode 100644 examples/simple/src/app/(checkout)/not-found.tsx rename examples/{payments/src/app => simple/src/app/(store)}/about/page.tsx (56%) create mode 100644 examples/simple/src/app/(store)/account/AccountNavigation.tsx create mode 100644 examples/simple/src/app/(store)/account/addresses/[addressId]/page.tsx create mode 100644 examples/simple/src/app/(store)/account/addresses/actions.ts create mode 100644 examples/simple/src/app/(store)/account/addresses/add/page.tsx create mode 100644 examples/simple/src/app/(store)/account/addresses/page.tsx create mode 100644 examples/simple/src/app/(store)/account/breadcrumb.tsx create mode 100644 examples/simple/src/app/(store)/account/layout.tsx create mode 100644 examples/simple/src/app/(store)/account/orders/OrderItem.tsx create mode 100644 examples/simple/src/app/(store)/account/orders/OrderItemWithDetails.tsx create mode 100644 examples/simple/src/app/(store)/account/orders/[orderId]/OrderLineItem.tsx create mode 100644 examples/simple/src/app/(store)/account/orders/[orderId]/ProductThumbnail.tsx create mode 100644 examples/simple/src/app/(store)/account/orders/[orderId]/page.tsx create mode 100644 examples/simple/src/app/(store)/account/orders/page.tsx create mode 100644 examples/simple/src/app/(store)/account/summary/YourInfoForm.tsx create mode 100644 examples/simple/src/app/(store)/account/summary/actions.ts create mode 100644 examples/simple/src/app/(store)/account/summary/page.tsx create mode 100644 examples/simple/src/app/(store)/cart/CartItem.tsx create mode 100644 examples/simple/src/app/(store)/cart/CartItemWide.tsx create mode 100644 examples/simple/src/app/(store)/cart/CartSidebar.tsx create mode 100644 examples/simple/src/app/(store)/cart/CartView.tsx create mode 100644 examples/simple/src/app/(store)/cart/YourBag.tsx create mode 100644 examples/simple/src/app/(store)/cart/page.tsx rename examples/{algolia/src/app => simple/src/app/(store)}/faq/page.tsx (55%) create mode 100644 examples/simple/src/app/(store)/layout.tsx create mode 100644 examples/simple/src/app/(store)/not-found.tsx rename examples/{algolia/src/app => simple/src/app/(store)}/page.tsx (84%) rename examples/simple/src/app/{ => (store)}/products/[productId]/page.tsx (73%) rename examples/{algolia/src/app => simple/src/app/(store)}/products/[productId]/product-display.tsx (52%) rename examples/{payments/src/app => simple/src/app/(store)}/search/[[...node]]/layout.tsx (79%) rename examples/simple/src/app/{ => (store)}/search/[[...node]]/page.tsx (97%) rename examples/simple/src/app/{ => (store)}/search/search.tsx (86%) rename examples/simple/src/app/{ => (store)}/shipping/page.tsx (58%) rename examples/{algolia/src/app => simple/src/app/(store)}/terms/page.tsx (60%) delete mode 100644 examples/simple/src/app/cart/page.tsx delete mode 100644 examples/simple/src/app/checkout/[cartId]/cart-display.tsx delete mode 100644 examples/simple/src/app/checkout/[cartId]/page.tsx create mode 100644 examples/simple/src/components/Checkbox.tsx create mode 100644 examples/simple/src/components/LoadingDots.tsx create mode 100644 examples/simple/src/components/accordion/Accordion.tsx create mode 100644 examples/simple/src/components/alert/Alert.tsx create mode 100644 examples/simple/src/components/button/Button.tsx create mode 100644 examples/simple/src/components/button/FormStatusButton.tsx create mode 100644 examples/simple/src/components/button/LoaderIcon.tsx create mode 100644 examples/simple/src/components/button/StatusButton.tsx create mode 100644 examples/simple/src/components/button/TextButton.tsx delete mode 100644 examples/simple/src/components/cart/Cart.tsx create mode 100644 examples/simple/src/components/cart/CartDiscounts.tsx delete mode 100644 examples/simple/src/components/cart/CartItemList.tsx delete mode 100644 examples/simple/src/components/cart/CartOrderSummary.tsx create mode 100644 examples/simple/src/components/cart/CartSheet.tsx delete mode 100644 examples/simple/src/components/cart/Promotion.tsx create mode 100644 examples/simple/src/components/checkout-item/CheckoutItem.tsx create mode 100644 examples/simple/src/components/checkout-sidebar/AddPromotion.tsx create mode 100644 examples/simple/src/components/checkout-sidebar/ItemSidebar.tsx create mode 100644 examples/simple/src/components/checkout-sidebar/actions.ts delete mode 100644 examples/simple/src/components/checkout/BillingForm.tsx delete mode 100644 examples/simple/src/components/checkout/CountrySelect.tsx delete mode 100644 examples/simple/src/components/checkout/CustomFormControl.tsx delete mode 100644 examples/simple/src/components/checkout/CustomFormSelect.tsx delete mode 100644 examples/simple/src/components/checkout/OrderComplete.tsx delete mode 100644 examples/simple/src/components/checkout/OrderSummary.tsx delete mode 100644 examples/simple/src/components/checkout/ShippingForm.tsx delete mode 100644 examples/simple/src/components/checkout/payments/CheckoutForm.tsx delete mode 100644 examples/simple/src/components/checkout/types/checkout-form.ts delete mode 100644 examples/simple/src/components/checkout/types/order-pending-state.ts create mode 100644 examples/simple/src/components/form/Form.tsx create mode 100644 examples/simple/src/components/header/AccountMobileMenu.tsx create mode 100644 examples/simple/src/components/header/account/AccountMenu.tsx create mode 100644 examples/simple/src/components/header/account/AccountSwitcher.tsx create mode 100644 examples/simple/src/components/header/account/switch-button.tsx delete mode 100644 examples/simple/src/components/header/cart/CartMenu.tsx delete mode 100644 examples/simple/src/components/header/cart/ModalCartItem.tsx create mode 100644 examples/simple/src/components/header/navigation/MobileAccountSwitcher.tsx create mode 100644 examples/simple/src/components/input/Input.tsx create mode 100644 examples/simple/src/components/label/Label.tsx create mode 100644 examples/simple/src/components/navigation-menu/NavigationMenu.tsx create mode 100644 examples/simple/src/components/number-input/EditQuantityButton.tsx create mode 100644 examples/simple/src/components/number-input/NumberInput.tsx create mode 100644 examples/simple/src/components/number-input/use-counter.ts create mode 100644 examples/simple/src/components/pagination/Pagination.tsx create mode 100644 examples/simple/src/components/pagination/ResourcePagination.tsx delete mode 100644 examples/simple/src/components/product/CartActions.tsx delete mode 100644 examples/simple/src/components/product/ProductContainer.tsx delete mode 100644 examples/simple/src/components/quantity-handler/QuantityHandler.tsx create mode 100644 examples/simple/src/components/radio-group/RadioGroup.tsx create mode 100644 examples/simple/src/components/select/Select.tsx create mode 100644 examples/simple/src/components/separator/Separator.tsx create mode 100644 examples/simple/src/components/sheet/Sheet.tsx create mode 100644 examples/simple/src/components/skeleton/Skeleton.tsx create mode 100644 examples/simple/src/hooks/use-countries.tsx create mode 100644 examples/simple/src/lib/all-countries.ts delete mode 100644 examples/simple/src/lib/cart-cookie-client.ts create mode 100644 examples/simple/src/lib/cn.tsx create mode 100644 examples/simple/src/lib/constants.ts delete mode 100644 examples/simple/src/lib/ep-client-store.ts create mode 100644 examples/simple/src/lib/epcc-server-side-credentials-client.ts create mode 100644 examples/simple/src/lib/format-currency.tsx create mode 100644 examples/simple/src/lib/format-iso-date-string.ts create mode 100644 examples/simple/src/lib/get-error-message.ts rename examples/simple/src/lib/{get-store-context.ts => get-store-initial-state.ts} (69%) create mode 100644 examples/simple/src/lib/is-account-member-authenticated.ts delete mode 100644 examples/simple/src/lib/parse-cookie.ts delete mode 100644 examples/simple/src/lib/providers/store-provider.tsx delete mode 100644 examples/simple/src/lib/resolve-shopping-cart-props.ts create mode 100644 examples/simple/src/lib/retrieve-account-member-credentials.ts delete mode 100644 examples/simple/src/lib/types/store-context.ts delete mode 100644 examples/simple/src/services/checkout.ts diff --git a/examples/algolia/README.md b/examples/algolia/README.md index e0a45c5a..b07f8d94 100644 --- a/examples/algolia/README.md +++ b/examples/algolia/README.md @@ -1,4 +1,4 @@ -# `BETA` Elastic Path D2C Starter Kit - mystorefront678 +# `BETA` Elastic Path D2C Starter Kit - algolia This project was generated with [Elastic Path Commerce Cloud CLI](https://www.elasticpath.com/). diff --git a/examples/algolia/e2e/checkout-flow.spec.ts b/examples/algolia/e2e/checkout-flow.spec.ts new file mode 100644 index 00000000..0fbeee3b --- /dev/null +++ b/examples/algolia/e2e/checkout-flow.spec.ts @@ -0,0 +1,48 @@ +import { test } from "@playwright/test"; +import { createD2CProductDetailPage } from "./models/d2c-product-detail-page"; +import { client } from "./util/epcc-client"; +import { createD2CCartPage } from "./models/d2c-cart-page"; +import { createD2CCheckoutPage } from "./models/d2c-checkout-page"; + +test.describe("Checkout flow", async () => { + test("should perform product checkout", async ({ page }) => { + const productDetailPage = createD2CProductDetailPage(page, client); + const cartPage = createD2CCartPage(page); + const checkoutPage = createD2CCheckoutPage(page); + + /* Go to simple product page */ + await productDetailPage.gotoSimpleProduct(); + + /* Add the product to cart */ + await productDetailPage.addProductToCart(); + + /* Go to cart page and checkout */ + await cartPage.goto(); + await cartPage.checkoutCart(); + + /* Enter information */ + await checkoutPage.enterInformation({ + Email: { value: "test@tester.com", fieldType: "input" }, + "First Name": { value: "Jim", fieldType: "input" }, + "Last Name": { value: "Brown", fieldType: "input" }, + "Street Address": { value: "Main Street", fieldType: "input" }, + "Extended Address": { value: "Extended Address", fieldType: "input" }, + City: { value: "Brownsville", fieldType: "input" }, + County: { value: "Brownsville County", fieldType: "input" }, + Region: { value: "Browns", fieldType: "input" }, + Postcode: { value: "ABC 123", fieldType: "input" }, + Country: { value: "Algeria", fieldType: "select" }, + "Phone Number": { value: "01234567891", fieldType: "input" }, + "Additional Instructions": { + value: "This is some extra instructions.", + fieldType: "input", + }, + }); + + await checkoutPage.checkout(); + await checkoutPage.checkOrderComplete; + + /* Continue Shopping */ + await checkoutPage.continueShopping(); + }); +}); diff --git a/examples/algolia/e2e/models/d2c-checkout-page.ts b/examples/algolia/e2e/models/d2c-checkout-page.ts new file mode 100644 index 00000000..74f1688f --- /dev/null +++ b/examples/algolia/e2e/models/d2c-checkout-page.ts @@ -0,0 +1,55 @@ +import type { Locator, Page } from "@playwright/test"; +import { fillAllFormFields, FormInput } from "../util/fill-form-field"; +import { expect } from "@playwright/test"; +import { enterPaymentInformation as _enterPaymentInformation } from "../util/enter-payment-information"; + +export interface D2CCheckoutPage { + readonly page: Page; + readonly payNowBtn: Locator; + readonly checkoutBtn: Locator; + readonly goto: () => Promise; + readonly enterInformation: (values: FormInput) => Promise; + readonly checkout: () => Promise; + readonly enterPaymentInformation: (values: FormInput) => Promise; + readonly submitPayment: () => Promise; + readonly checkOrderComplete: () => Promise; + readonly continueShopping: () => Promise; +} + +export function createD2CCheckoutPage(page: Page): D2CCheckoutPage { + const payNowBtn = page.getByRole("button", { name: "Pay now" }); + const checkoutBtn = page.getByRole("button", { name: "Checkout Now" }); + const continueShoppingBtn = page.getByRole("button", { + name: "Continue Shopping", + }); + + return { + page, + payNowBtn, + checkoutBtn, + async goto() { + await page.goto(`/cart`); + }, + async enterPaymentInformation(values: FormInput) { + await _enterPaymentInformation(page, values); + }, + async enterInformation(values: FormInput) { + await fillAllFormFields(page, values); + }, + async submitPayment() { + await payNowBtn.click(); + }, + async checkout() { + await checkoutBtn.click(); + }, + async checkOrderComplete() { + await page.getByText("Thank you for your order!"); + }, + async continueShopping() { + await continueShoppingBtn.click(); + await expect( + page.getByRole("heading", { name: "Your Elastic Path storefront" }), + ).toBeVisible(); + }, + }; +} diff --git a/examples/algolia/e2e/util/enter-payment-information.ts b/examples/algolia/e2e/util/enter-payment-information.ts new file mode 100644 index 00000000..738196b7 --- /dev/null +++ b/examples/algolia/e2e/util/enter-payment-information.ts @@ -0,0 +1,10 @@ +import { Page } from "@playwright/test"; +import { fillAllFormFields, FormInput } from "./fill-form-field"; + +export async function enterPaymentInformation(page: Page, values: FormInput) { + const paymentIframe = await page + .locator('[id="payment-element"]') + .frameLocator("iframe"); + + await fillAllFormFields(paymentIframe, values); +} diff --git a/examples/algolia/e2e/util/gateway-check.ts b/examples/algolia/e2e/util/gateway-check.ts new file mode 100644 index 00000000..b1ecf29c --- /dev/null +++ b/examples/algolia/e2e/util/gateway-check.ts @@ -0,0 +1,13 @@ +import type { Moltin as EPCCClient } from "@moltin/sdk"; + +export async function gatewayCheck(client: EPCCClient): Promise { + try { + const gateways = await client.Gateways.All(); + const epPaymentGateway = gateways.data.find( + (gateway) => gateway.slug === "elastic_path_payments_stripe", + )?.enabled; + return !!epPaymentGateway; + } catch (err) { + return false; + } +} diff --git a/examples/algolia/e2e/util/gateway-is-enabled.ts b/examples/algolia/e2e/util/gateway-is-enabled.ts new file mode 100644 index 00000000..12e55d3a --- /dev/null +++ b/examples/algolia/e2e/util/gateway-is-enabled.ts @@ -0,0 +1,10 @@ +import { test } from "@playwright/test"; +import { gatewayCheck } from "./gateway-check"; +import { adminClient } from "./epcc-admin-client"; + +export async function gatewayIsEnabled(): Promise { + test.skip( + !(await gatewayCheck(adminClient)), + "Skipping tests because they payment gateway is not enabled.", + ); +} diff --git a/examples/algolia/package.json b/examples/algolia/package.json index 2da3264d..13f5dab1 100644 --- a/examples/algolia/package.json +++ b/examples/algolia/package.json @@ -19,28 +19,45 @@ "start:e2e": "NODE_ENV=test next start" }, "dependencies": { - "@algolia/react-instantsearch-widget-color-refinement-list": "^1.4.7", + "@algolia/react-instantsearch-widget-color-refinement-list": "1.4.7", "@elasticpath/react-shopper-hooks": "workspace:*", "@elasticpath/shopper-common": "workspace:*", + "@floating-ui/react": "^0.26.3", "@headlessui/react": "^1.7.17", "@heroicons/react": "^2.0.18", - "@moltin/sdk": "^27.0.0", + "@hookform/error-message": "^2.0.1", + "@hookform/resolvers": "^3.3.2", + "@moltin/sdk": "^27.6.0", + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slot": "^1.0.2", + "@tanstack/react-query": "^5.17.15", "algoliasearch": "^4.14.2", + "class-variance-authority": "^0.7.0", "clsx": "^1.2.1", "cookies-next": "^4.0.0", "focus-visible": "^5.2.0", "formik": "^2.2.9", - "instantsearch.js": "4.59.0", + "instantsearch.js": "4.64.0", "next": "^14.0.0", "pure-react-carousel": "^1.29.0", "rc-slider": "^10.3.0", "react": "^18.2.0", "react-device-detect": "^2.2.2", "react-dom": "^18.2.0", - "react-instantsearch": "^7.2.0", - "react-instantsearch-nextjs": "^0.1.2", + "react-hook-form": "^7.49.0", + "react-instantsearch": "7.5.2", + "react-instantsearch-nextjs": "0.1.9", "react-toastify": "^9.1.3", "server-only": "^0.0.1", + "tailwind-clip-path": "^1.0.0", + "tailwind-merge": "^2.0.0", "zod": "^3.22.4", "zod-formik-adapter": "^1.2.0" }, @@ -72,6 +89,8 @@ "tailwindcss": "^3.3.3", "autoprefixer": "^10.4.14", "postcss": "^8.4.30", - "prettier-plugin-tailwindcss": "^0.5.4" + "prettier-plugin-tailwindcss": "^0.5.4", + "@tailwindcss/forms": "^0.5.7", + "tailwindcss-animate": "^1.0.7" } } \ No newline at end of file diff --git a/examples/algolia/src/app/(auth)/account-memeber-credentials-schema.ts b/examples/algolia/src/app/(auth)/account-memeber-credentials-schema.ts new file mode 100644 index 00000000..19e7a5fc --- /dev/null +++ b/examples/algolia/src/app/(auth)/account-memeber-credentials-schema.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; + +export const accountMemberCredentialSchema = z.object({ + account_id: z.string(), + account_name: z.string(), + expires: z.string(), + token: z.string(), + type: z.literal("account_management_authentication_token"), +}); + +export type AccountMemberCredential = z.infer< + typeof accountMemberCredentialSchema +>; + +export const accountMemberCredentialsSchema = z.object({ + accounts: z.record(z.string(), accountMemberCredentialSchema), + selected: z.string(), + accountMemberId: z.string(), +}); + +export type AccountMemberCredentials = z.infer< + typeof accountMemberCredentialsSchema +>; diff --git a/examples/algolia/src/app/(auth)/actions.ts b/examples/algolia/src/app/(auth)/actions.ts new file mode 100644 index 00000000..7f67765e --- /dev/null +++ b/examples/algolia/src/app/(auth)/actions.ts @@ -0,0 +1,200 @@ +"use server"; + +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { z } from "zod"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../lib/cookie-constants"; +import { AccountTokenBase, ResourcePage } from "@moltin/sdk"; +import { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies"; +import { + AccountMemberCredential, + AccountMemberCredentials, +} from "./account-memeber-credentials-schema"; +import { retrieveAccountMemberCredentials } from "../../lib/retrieve-account-member-credentials"; +import { revalidatePath } from "next/cache"; +import { getErrorMessage } from "../../lib/get-error-message"; + +const loginSchema = z.object({ + email: z.string().email(), + password: z.string(), + returnUrl: z.string().optional(), +}); + +const selectedAccountSchema = z.object({ + accountId: z.string(), +}); + +const registerSchema = z.object({ + email: z.string().email(), + password: z.string(), + name: z.string(), +}); + +const PASSWORD_PROFILE_ID = process.env.NEXT_PUBLIC_PASSWORD_PROFILE_ID!; + +const loginErrorMessage = + "Failed to login, make sure your email and password are correct"; + +export async function login(props: FormData) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(props.entries()); + + const validatedProps = loginSchema.safeParse(rawEntries); + + if (!validatedProps.success) { + return { + error: loginErrorMessage, + }; + } + + const { email, password, returnUrl } = validatedProps.data; + + try { + const result = await client.AccountMembers.GenerateAccountToken({ + type: "account_management_authentication_token", + authentication_mechanism: "password", + password_profile_id: PASSWORD_PROFILE_ID, + username: email.toLowerCase(), // Known bug for uppercase usernames so we force lowercase. + password, + }); + + const cookieStore = cookies(); + cookieStore.set(createCookieFromGenerateTokenResponse(result)); + } catch (error) { + console.error(getErrorMessage(error)); + return { + error: loginErrorMessage, + }; + } + + redirect(returnUrl ?? "/"); +} + +export async function logout() { + const cookieStore = cookies(); + + cookieStore.delete(ACCOUNT_MEMBER_TOKEN_COOKIE_NAME); + + redirect("/"); +} + +export async function selectedAccount(args: FormData) { + const rawEntries = Object.fromEntries(args.entries()); + + const validatedProps = selectedAccountSchema.safeParse(rawEntries); + + if (!validatedProps.success) { + throw new Error("Invalid account id"); + } + + const { accountId } = validatedProps.data; + + const cookieStore = cookies(); + + const accountMemberCredentials = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCredentials) { + redirect("/login"); + return; + } + + const selectedAccount = accountMemberCredentials?.accounts[accountId]; + + if (!selectedAccount) { + throw new Error("Invalid account id"); + } + + cookieStore.set({ + name: ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + value: JSON.stringify({ + ...accountMemberCredentials, + selected: selectedAccount.account_id, + }), + path: "/", + sameSite: "strict", + expires: new Date( + accountMemberCredentials.accounts[ + Object.keys(accountMemberCredentials.accounts)[0] + ].expires, + ), + }); + + revalidatePath("/account"); +} + +export async function register(data: FormData) { + const client = getServerSideImplicitClient(); + + const validatedProps = registerSchema.safeParse( + Object.fromEntries(data.entries()), + ); + + if (!validatedProps.success) { + throw new Error("Invalid email or password or name"); + } + + const { email, password, name } = validatedProps.data; + + const result = await client.AccountMembers.GenerateAccountToken({ + type: "account_management_authentication_token", + authentication_mechanism: "self_signup", + password_profile_id: PASSWORD_PROFILE_ID, + username: email.toLowerCase(), // Known bug for uppercase usernames so we force lowercase. + password, + name, // TODO update sdk types as name should exist + email, + } as any); + + const cookieStore = cookies(); + cookieStore.set(createCookieFromGenerateTokenResponse(result)); + + redirect("/"); +} + +function createCookieFromGenerateTokenResponse( + response: ResourcePage, +): ResponseCookie { + const { expires } = response.data[0]; // assuming all tokens have shared expiration date/time + + const cookieValue = createAccountMemberCredentialsCookieValue( + response.data, + (response.meta as unknown as { account_member_id: string }) + .account_member_id, // TODO update sdk types + ); + + return { + name: ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + value: JSON.stringify(cookieValue), + path: "/", + sameSite: "strict", + expires: new Date(expires), + }; +} + +function createAccountMemberCredentialsCookieValue( + responseTokens: AccountTokenBase[], + accountMemberId: string, +): AccountMemberCredentials { + return { + accounts: responseTokens.reduce( + (acc, responseToken) => ({ + ...acc, + [responseToken.account_id]: { + account_id: responseToken.account_id, + account_name: responseToken.account_name, + expires: responseToken.expires, + token: responseToken.token, + type: "account_management_authentication_token" as const, + }, + }), + {} as Record, + ), + selected: responseTokens[0].account_id, + accountMemberId, + }; +} diff --git a/examples/algolia/src/app/(auth)/layout.tsx b/examples/algolia/src/app/(auth)/layout.tsx new file mode 100644 index 00000000..b655a9bc --- /dev/null +++ b/examples/algolia/src/app/(auth)/layout.tsx @@ -0,0 +1,51 @@ +import { Inter } from "next/font/google"; +import { ReactNode } from "react"; +import { getStoreInitialState } from "../../lib/get-store-initial-state"; +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { Providers } from "../providers"; +import clsx from "clsx"; + +const { SITE_NAME } = process.env; +const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL + ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` + : "http://localhost:3000"; + +export const metadata = { + metadataBase: new URL(baseUrl), + title: { + default: SITE_NAME!, + template: `%s | ${SITE_NAME}`, + }, + robots: { + follow: true, + index: true, + }, +}; + +const inter = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-inter", +}); + +export default async function AuthLayout({ + children, +}: { + children: ReactNode; +}) { + const client = getServerSideImplicitClient(); + const initialState = await getStoreInitialState(client); + + return ( + + + {/* headless ui needs this div - https://github.com/tailwindlabs/headlessui/issues/2752#issuecomment-1745272229 */} +
+ +
{children}
+
+
+ + + ); +} diff --git a/examples/algolia/src/app/(auth)/login/LoginForm.tsx b/examples/algolia/src/app/(auth)/login/LoginForm.tsx new file mode 100644 index 00000000..a89c4a5c --- /dev/null +++ b/examples/algolia/src/app/(auth)/login/LoginForm.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { login } from "../actions"; +import { Label } from "../../../components/label/Label"; +import { Input } from "../../../components/input/Input"; +import { FormStatusButton } from "../../../components/button/FormStatusButton"; +import { useState } from "react"; + +export function LoginForm({ returnUrl }: { returnUrl?: string }) { + const [error, setError] = useState(undefined); + + async function loginAction(formData: FormData) { + const result = await login(formData); + + if ("error" in result) { + setError(result.error); + } + } + + return ( +
+
+ +
+ +
+
+ +
+
+ + +
+
+ +
+
+ {returnUrl && ( + + )} + {error && ( +
+ {error} +
+ )} + +
+ Login +
+
+ ); +} diff --git a/examples/algolia/src/app/(auth)/login/page.tsx b/examples/algolia/src/app/(auth)/login/page.tsx new file mode 100644 index 00000000..7f814ed2 --- /dev/null +++ b/examples/algolia/src/app/(auth)/login/page.tsx @@ -0,0 +1,49 @@ +import EpLogo from "../../../components/icons/ep-logo"; +import { cookies } from "next/headers"; +import { isAccountMemberAuthenticated } from "../../../lib/is-account-member-authenticated"; +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { LoginForm } from "./LoginForm"; + +export default function Login({ + searchParams, +}: { + searchParams: { returnUrl?: string }; +}) { + const { returnUrl } = searchParams; + + const cookieStore = cookies(); + + if (isAccountMemberAuthenticated(cookieStore)) { + redirect("/account/summary"); + } + + return ( + <> +
+
+ + + +

+ Sign in to your account +

+
+ +
+ + +

+ Not a member?{" "} + + Register now! + +

+
+
+ + ); +} diff --git a/examples/algolia/src/app/(auth)/not-found.tsx b/examples/algolia/src/app/(auth)/not-found.tsx new file mode 100644 index 00000000..d97d88f4 --- /dev/null +++ b/examples/algolia/src/app/(auth)/not-found.tsx @@ -0,0 +1,13 @@ +import Link from "next/link"; +export default function NotFound() { + return ( +
+ + 404 - The page could not be found. + + + Back to home + +
+ ); +} diff --git a/examples/algolia/src/app/(auth)/register/page.tsx b/examples/algolia/src/app/(auth)/register/page.tsx new file mode 100644 index 00000000..125ad4e6 --- /dev/null +++ b/examples/algolia/src/app/(auth)/register/page.tsx @@ -0,0 +1,97 @@ +import { register } from "../actions"; +import EpLogo from "../../../components/icons/ep-logo"; +import { cookies } from "next/headers"; +import { isAccountMemberAuthenticated } from "../../../lib/is-account-member-authenticated"; +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { Label } from "../../../components/label/Label"; +import { Input } from "../../../components/input/Input"; +import { FormStatusButton } from "../../../components/button/FormStatusButton"; + +export default function Register() { + const cookieStore = cookies(); + if (isAccountMemberAuthenticated(cookieStore)) { + redirect("/account/summary"); + } + + return ( + <> +
+
+ + + +

+ Register for an account +

+
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+ + +
+
+ +
+
+ +
+ Register +
+
+ +

+ Already a member?{" "} + + Login now! + +

+
+
+ + ); +} diff --git a/examples/algolia/src/app/(checkout)/checkout/AccountCheckout.tsx b/examples/algolia/src/app/(checkout)/checkout/AccountCheckout.tsx new file mode 100644 index 00000000..e85d1651 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/AccountCheckout.tsx @@ -0,0 +1,52 @@ +import Link from "next/link"; +import EpIcon from "../../../components/icons/ep-icon"; +import { Separator } from "../../../components/separator/Separator"; +import { DeliveryForm } from "./DeliveryForm"; +import { PaymentForm } from "./PaymentForm"; +import { BillingForm } from "./BillingForm"; +import { SubmitCheckoutButton } from "./SubmitCheckoutButton"; +import { CheckoutSidebar } from "./CheckoutSidebar"; +import { AccountDisplay } from "./AccountDisplay"; +import { ShippingSelector } from "./ShippingSelector"; + +export function AccountCheckout() { + return ( +
+
+ + + +
+
+
+
+ + + +
+ +
+ Your Info + +
+
+ Shipping address + +
+ + +
+ +
+
+ +
+
+
+ {/* Sidebar */} + +
+
+
+ ); +} diff --git a/examples/algolia/src/app/(checkout)/checkout/AccountDisplay.tsx b/examples/algolia/src/app/(checkout)/checkout/AccountDisplay.tsx new file mode 100644 index 00000000..47f400f4 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/AccountDisplay.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useAuthedAccountMember } from "@elasticpath/react-shopper-hooks"; +import { Button } from "../../../components/button/Button"; +import { FormControl, FormField } from "../../../components/form/Form"; +import { Input } from "../../../components/input/Input"; +import React, { useEffect, useTransition } from "react"; +import { useFormContext } from "react-hook-form"; +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { logout } from "../../(auth)/actions"; +import { Skeleton } from "../../../components/skeleton/Skeleton"; + +export function AccountDisplay() { + const { data: accountMember } = useAuthedAccountMember(); + + const { control, setValue } = useFormContext(); + + const [isPending, startTransition] = useTransition(); + + useEffect(() => { + if (accountMember?.email && accountMember?.name) { + setValue("account", { + email: accountMember.email, + name: accountMember.name, + }); + } + }, [accountMember, setValue]); + + return ( +
+
+ {accountMember ? ( + <> + {accountMember?.name} + {accountMember?.email} + + ) : ( +
+ + +
+ )} +
+ + {accountMember && ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + + )} +
+ ); +} diff --git a/examples/algolia/src/app/(checkout)/checkout/BillingForm.tsx b/examples/algolia/src/app/(checkout)/checkout/BillingForm.tsx new file mode 100644 index 00000000..0804c54c --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/BillingForm.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { Checkbox } from "../../../components/Checkbox"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../components/form/Form"; +import { useFormContext, useWatch } from "react-hook-form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../components/select/Select"; +import { Input } from "../../../components/input/Input"; +import React, { useEffect } from "react"; +import { useCountries } from "../../../hooks/use-countries"; + +export function BillingForm() { + const { control, resetField } = useFormContext(); + const { data: countries } = useCountries(); + const isSameAsShipping = useWatch({ control, name: "sameAsShipping" }); + + useEffect(() => { + // Reset the billing address fields when the user selects the same as shipping address + if (isSameAsShipping) { + resetField("billingAddress", { + keepDirty: false, + keepTouched: false, + keepError: false, + }); + } + }, [isSameAsShipping, resetField]); + + return ( +
+
+ Billing address +
+
+ ( + + + + +
+ Same as shipping address +
+
+ )} + /> +
+ {!isSameAsShipping && ( +
+ ( + + Country + + + + )} + /> +
+ ( + + First Name + + + + + + )} + /> + ( + + Last Name + + + + + + )} + /> +
+ ( + + Company (optional) + + + + + + )} + /> + ( + + Address + + + + + + )} + /> +
+ ( + + City + + + + + + )} + /> + ( + + Region + + + + + + )} + /> + ( + + Postcode + + + + + + )} + /> +
+
+ )} +
+ ); +} diff --git a/examples/algolia/src/app/(checkout)/checkout/CheckoutFooter.tsx b/examples/algolia/src/app/(checkout)/checkout/CheckoutFooter.tsx new file mode 100644 index 00000000..9882b3a1 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/CheckoutFooter.tsx @@ -0,0 +1,30 @@ +import { Separator } from "../../../components/separator/Separator"; +import Link from "next/link"; +import EpLogo from "../../../components/icons/ep-logo"; +import * as React from "react"; + +export function CheckoutFooter() { + return ( +
+ +
+ + Refund Policy + + + Shipping Policy + + + Privacy Policy + + + Terms of Service + +
+
+ Powered by + +
+
+ ); +} diff --git a/examples/algolia/src/app/(checkout)/checkout/CheckoutSidebar.tsx b/examples/algolia/src/app/(checkout)/checkout/CheckoutSidebar.tsx new file mode 100644 index 00000000..8d19d883 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/CheckoutSidebar.tsx @@ -0,0 +1,102 @@ +"use client"; +import { Separator } from "../../../components/separator/Separator"; +import { CartDiscounts } from "../../../components/cart/CartDiscounts"; +import * as React from "react"; +import { useCart, useCurrencies } from "@elasticpath/react-shopper-hooks"; +import { + ItemSidebarHideable, + ItemSidebarItems, + ItemSidebarPromotions, + ItemSidebarTotals, + ItemSidebarTotalsDiscount, + ItemSidebarTotalsSubTotal, + ItemSidebarTotalsTax, + resolveTotalInclShipping, +} from "../../../components/checkout-sidebar/ItemSidebar"; +import { staticDeliveryMethods } from "./useShippingMethod"; +import { cn } from "../../../lib/cn"; +import { useWatch } from "react-hook-form"; +import { EP_CURRENCY_CODE } from "../../../lib/resolve-ep-currency-code"; +import { formatCurrency } from "../../../lib/format-currency"; +import { LoadingDots } from "../../../components/LoadingDots"; + +export function CheckoutSidebar() { + const { state } = useCart(); + const shippingMethod = useWatch({ name: "shippingMethod" }); + + const { data } = useCurrencies(); + + const storeCurrency = data?.find( + (currency) => currency.code === EP_CURRENCY_CODE, + ); + + if (!state) { + return null; + } + + const shippingAmount = staticDeliveryMethods.find( + (method) => method.value === shippingMethod, + )?.amount; + + const { meta, __extended } = state; + + const formattedTotalAmountInclShipping = + meta?.display_price?.with_tax?.amount !== undefined && + shippingAmount !== undefined && + storeCurrency + ? resolveTotalInclShipping( + shippingAmount, + meta.display_price.with_tax.amount, + storeCurrency, + ) + : undefined; + + return ( + +
+ + + + + {/* Totals */} + + +
+ Shipping + + {shippingAmount === undefined ? ( + "Select delivery method" + ) : storeCurrency ? ( + formatCurrency(shippingAmount, storeCurrency) + ) : ( + + )} + +
+ + +
+ + {/* Sum total incl shipping */} + {formattedTotalAmountInclShipping ? ( +
+ Total +
+ {meta?.display_price?.with_tax?.currency} + + {formattedTotalAmountInclShipping} + +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/examples/algolia/src/app/(checkout)/checkout/CheckoutViews.tsx b/examples/algolia/src/app/(checkout)/checkout/CheckoutViews.tsx new file mode 100644 index 00000000..d88c5115 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/CheckoutViews.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { useCheckout } from "./checkout-provider"; +import { ReactNode } from "react"; +import { OrderConfirmation } from "./OrderConfirmation"; + +export function CheckoutViews({ children }: { children: ReactNode }) { + const { confirmationData } = useCheckout(); + + if (confirmationData) { + return ; + } + + return children; +} diff --git a/examples/algolia/src/app/(checkout)/checkout/ConfirmationSidebar.tsx b/examples/algolia/src/app/(checkout)/checkout/ConfirmationSidebar.tsx new file mode 100644 index 00000000..708d1549 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/ConfirmationSidebar.tsx @@ -0,0 +1,108 @@ +"use client"; +import { Separator } from "../../../components/separator/Separator"; +import { CartDiscountsReadOnly } from "../../../components/cart/CartDiscounts"; +import * as React from "react"; +import { + groupCartItems, + useCurrencies, +} from "@elasticpath/react-shopper-hooks"; +import { + ItemSidebarHideable, + ItemSidebarItems, + ItemSidebarTotals, + ItemSidebarTotalsDiscount, + ItemSidebarTotalsSubTotal, + ItemSidebarTotalsTax, + resolveTotalInclShipping, +} from "../../../components/checkout-sidebar/ItemSidebar"; +import { useCheckout } from "./checkout-provider"; +import { staticDeliveryMethods } from "./useShippingMethod"; +import { EP_CURRENCY_CODE } from "../../../lib/resolve-ep-currency-code"; +import { LoadingDots } from "../../../components/LoadingDots"; + +export function ConfirmationSidebar() { + const { confirmationData } = useCheckout(); + + const { data: currencyData } = useCurrencies(); + + if (!confirmationData) { + return null; + } + + const { order, cart } = confirmationData; + + const groupedItems = groupCartItems(cart.data); + + const shippingMethodCustomItem = groupedItems.custom.find((item) => + item.sku.startsWith("__shipping_"), + ); + + const meta = { + display_price: order.data.meta.display_price, + }; + + const shippingAmount = staticDeliveryMethods.find( + (method) => + !!shippingMethodCustomItem && + method.value === shippingMethodCustomItem.sku, + )?.amount; + + const storeCurrency = currencyData?.find( + (currency) => currency.code === EP_CURRENCY_CODE, + ); + + const formattedTotalAmountInclShipping = + meta?.display_price?.with_tax?.amount !== undefined && + shippingAmount !== undefined && + storeCurrency + ? resolveTotalInclShipping( + shippingAmount, + meta.display_price.with_tax.amount, + storeCurrency, + ) + : undefined; + + return ( + +
+
+ +
+ Discounts applied + + {/* Totals */} + + + {shippingMethodCustomItem && ( +
+ Shipping + + { + shippingMethodCustomItem.meta.display_price.with_tax.value + .formatted + } + +
+ )} + + +
+ + {/* Sum total incl shipping */} + {formattedTotalAmountInclShipping ? ( +
+ Total +
+ {meta?.display_price?.with_tax?.currency} + + {formattedTotalAmountInclShipping} + +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/examples/algolia/src/app/(checkout)/checkout/DeliveryForm.tsx b/examples/algolia/src/app/(checkout)/checkout/DeliveryForm.tsx new file mode 100644 index 00000000..baa58998 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/DeliveryForm.tsx @@ -0,0 +1,108 @@ +"use client"; +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { + RadioGroup, + RadioGroupItem, +} from "../../../components/radio-group/RadioGroup"; +import { Label } from "../../../components/label/Label"; +import { cn } from "../../../lib/cn"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../components/form/Form"; +import { useFormContext } from "react-hook-form"; +import { useShippingMethod } from "./useShippingMethod"; +import { LightBulbIcon } from "@heroicons/react/24/outline"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "../../../components/alert/Alert"; +import { Skeleton } from "../../../components/skeleton/Skeleton"; + +export function DeliveryForm() { + const { control } = useFormContext(); + const { data: deliveryOptions } = useShippingMethod(); + + return ( +
+
+ Delivery +
+ + + Delivery is using fixed rates! + +

+ Delivery is fixed rate data for testing. You can replace this with a + 3rd party service. +

+
+
+ {!deliveryOptions ? ( +
+ + +
+ ) : ( + ( + + Shipping Method + + + {deliveryOptions.map((option, optionIndex) => { + return ( +
+
+ + +
+ {option.formatted} +
+ ); + })} +
+
+ +
+ )} + /> + )} +
+ ); +} diff --git a/examples/algolia/src/app/(checkout)/checkout/FormInput.tsx b/examples/algolia/src/app/(checkout)/checkout/FormInput.tsx new file mode 100644 index 00000000..c50a2606 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/FormInput.tsx @@ -0,0 +1,73 @@ +import { ErrorMessage } from "@hookform/error-message"; +import React, { forwardRef, useImperativeHandle, useRef } from "react"; +import { get } from "react-hook-form"; +import { Label } from "../../../components/label/Label"; +import { Input } from "../../../components/input/Input"; +import { cn } from "../../../lib/cn"; + +type InputProps = Omit< + Omit, "size">, + "placeholder" +> & { + label: string; + errors?: Record; + touched?: Record; + name: string; +}; + +export const FormInput = forwardRef( + ({ type, name, label, errors, touched, required, ...props }, ref) => { + const inputRef = useRef(null); + + useImperativeHandle(ref, () => inputRef.current!); + + const hasError = get(errors, name) && get(touched, name); + + return ( +
+
+
+

+ + +

+
+
+ {hasError && ( + { + return ( +
+ {message} +
+ ); + }} + /> + )} +
+ ); + }, +); + +FormInput.displayName = "FormInput"; diff --git a/examples/algolia/src/app/(checkout)/checkout/GuestCheckout.tsx b/examples/algolia/src/app/(checkout)/checkout/GuestCheckout.tsx new file mode 100644 index 00000000..8fcb4655 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/GuestCheckout.tsx @@ -0,0 +1,51 @@ +import { GuestInformation } from "./GuestInformation"; +import { ShippingForm } from "./ShippingForm"; +import Link from "next/link"; +import EpIcon from "../../../components/icons/ep-icon"; +import { DeliveryForm } from "./DeliveryForm"; +import { PaymentForm } from "./PaymentForm"; +import { BillingForm } from "./BillingForm"; +import { SubmitCheckoutButton } from "./SubmitCheckoutButton"; +import { Separator } from "../../../components/separator/Separator"; +import * as React from "react"; +import { CheckoutSidebar } from "./CheckoutSidebar"; + +export async function GuestCheckout() { + return ( +
+
+ + + +
+
+
+
+ + + +
+ +
+ +
+
+ +
+ + +
+ +
+
+ +
+
+
+ {/* Sidebar */} + +
+
+
+ ); +} diff --git a/examples/algolia/src/app/(checkout)/checkout/GuestInformation.tsx b/examples/algolia/src/app/(checkout)/checkout/GuestInformation.tsx new file mode 100644 index 00000000..fa22e559 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/GuestInformation.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../components/form/Form"; +import { Input } from "../../../components/input/Input"; +import React from "react"; +import { useFormContext } from "react-hook-form"; + +export function GuestInformation() { + const pathname = usePathname(); + + const { control } = useFormContext(); + + return ( +
+
+ Your Info + + Already a customer?{" "} + + Sign in + + +
+ ( + + Email + + + + + + )} + /> +
+ ); +} diff --git a/examples/algolia/src/app/(checkout)/checkout/OrderConfirmation.tsx b/examples/algolia/src/app/(checkout)/checkout/OrderConfirmation.tsx new file mode 100644 index 00000000..0671f6b3 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/OrderConfirmation.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useCheckout } from "./checkout-provider"; +import { ConfirmationSidebar } from "./ConfirmationSidebar"; +import Link from "next/link"; +import EpIcon from "../../../components/icons/ep-icon"; +import * as React from "react"; +import { Separator } from "../../../components/separator/Separator"; +import { CheckoutFooter } from "./CheckoutFooter"; + +export function OrderConfirmation() { + const { confirmationData } = useCheckout(); + + if (!confirmationData) { + return null; + } + + const { order } = confirmationData; + + const customerName = ( + order.data.contact?.name ?? + order.data.customer.name ?? + "" + ).split(" ")[0]; + + const { shipping_address, id: orderId } = order.data; + + return ( +
+
+ + + +
+
+ {/* Confirmation Content */} +
+
+ + + +
+ + + Thanks{customerName ? ` ${customerName}` : ""}! + + + Order #{orderId} is confirmed. + + {/* Shipping */} +
+

Ship to

+
+ {`${shipping_address.first_name} ${shipping_address.last_name}`} +

{shipping_address.line_1}

+ {`${shipping_address.region}, ${shipping_address.postcode}`} + {shipping_address.phone_number && ( + {shipping_address.phone_number} + )} +
+
+ {/* Delivery Method */} +
+

Delivery Method

+

placeholder

+
+ {/* Contact us */} +
+

Need to make changes?

+

+ Email us or call. Remember to reference order #{orderId} +

+
+ +
+ {/* Confirmation Sidebar */} +
+
+ {`Order #${orderId}`} + + +
+
+
+
+ ); +} diff --git a/examples/algolia/src/app/(checkout)/checkout/PaymentForm.tsx b/examples/algolia/src/app/(checkout)/checkout/PaymentForm.tsx new file mode 100644 index 00000000..ad609d01 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/PaymentForm.tsx @@ -0,0 +1,30 @@ +"use client"; +import { LightBulbIcon } from "@heroicons/react/24/outline"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "../../../components/alert/Alert"; + +export function PaymentForm() { + return ( +
+
+ Payment +
+ + + Payments set to manual! + +

+ Manual payments are enabled. To test the checkout flow, configure an + alternate payment gateway to take real payments. +

+

+ To checkout with manual payments you can just complete the order. +

+
+
+
+ ); +} diff --git a/examples/algolia/src/app/(checkout)/checkout/ShippingForm.tsx b/examples/algolia/src/app/(checkout)/checkout/ShippingForm.tsx new file mode 100644 index 00000000..5f9ab548 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/ShippingForm.tsx @@ -0,0 +1,217 @@ +"use client"; + +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { useFormContext } from "react-hook-form"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "../../../components/form/Form"; +import { Input } from "../../../components/input/Input"; +import React from "react"; +import { useCountries } from "../../../hooks/use-countries"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../components/select/Select"; + +export function ShippingForm() { + const { control } = useFormContext(); + const { data: countries } = useCountries(); + + return ( +
+
+ Shipping address +
+
+ ( + + Country + + + + )} + /> +
+ ( + + First Name + + + + + + )} + /> + ( + + Last Name + + + + + + )} + /> +
+ ( + + Company (optional) + + + + + + )} + /> + ( + + Address + + + + + + )} + /> +
+ ( + + City + + + + + + )} + /> + ( + + Region + + + + + + )} + /> + ( + + Postcode + + + + + + )} + /> +
+ ( + + Phone Number (optional) + + + + + + )} + /> +
+
+ ); +} diff --git a/examples/algolia/src/app/(checkout)/checkout/ShippingSelector.tsx b/examples/algolia/src/app/(checkout)/checkout/ShippingSelector.tsx new file mode 100644 index 00000000..ac1b907f --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/ShippingSelector.tsx @@ -0,0 +1,99 @@ +"use client"; +import { + useAccountAddresses, + useAuthedAccountMember, +} from "@elasticpath/react-shopper-hooks"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../components/select/Select"; +import { useFormContext, useWatch } from "react-hook-form"; +import { CheckoutForm as CheckoutFormSchemaType } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { AccountAddress } from "@moltin/sdk"; +import { useEffect } from "react"; +import { Skeleton } from "../../../components/skeleton/Skeleton"; +import { Button } from "../../../components/button/Button"; +import Link from "next/link"; + +export function ShippingSelector() { + const { selectedAccountToken } = useAuthedAccountMember(); + + const { data: accountAddressData } = useAccountAddresses( + selectedAccountToken?.account_id!, + { + ep: { accountMemberToken: selectedAccountToken?.token }, + enabled: !!selectedAccountToken, + }, + ); + + const { setValue } = useFormContext(); + + function updateAddress(addressId: string, addresses: AccountAddress[]) { + const address = addresses.find((address) => address.id === addressId); + + if (address) { + setValue("shippingAddress", { + postcode: address.postcode, + line_1: address.line_1, + line_2: address.line_2, + city: address.city, + county: address.county, + country: address.country, + company_name: address.company_name, + first_name: address.first_name, + last_name: address.last_name, + phone_number: address.phone_number, + instructions: address.instructions, + region: address.region, + }); + } + } + + useEffect(() => { + if (accountAddressData && accountAddressData[0]) { + updateAddress(accountAddressData[0].id, accountAddressData); + } + }, [accountAddressData]); + + return ( +
+ {accountAddressData ? ( + + ) : ( + + )} +
+ ); +} diff --git a/examples/algolia/src/app/(checkout)/checkout/SubmitCheckoutButton.tsx b/examples/algolia/src/app/(checkout)/checkout/SubmitCheckoutButton.tsx new file mode 100644 index 00000000..e8cb622b --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/SubmitCheckoutButton.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useCheckout } from "./checkout-provider"; +import { useCart } from "@elasticpath/react-shopper-hooks"; +import { StatusButton } from "../../../components/button/StatusButton"; + +export function SubmitCheckoutButton() { + const { handleSubmit, completePayment, isCompleting } = useCheckout(); + const { state } = useCart(); + + if (!state) { + return null; + } + + return ( + { + completePayment.mutate({ data: values }); + })} + > + {`Pay ${state.meta?.display_price?.with_tax?.formatted}`} + + ); +} diff --git a/examples/algolia/src/app/(checkout)/checkout/checkout-provider.tsx b/examples/algolia/src/app/(checkout)/checkout/checkout-provider.tsx new file mode 100644 index 00000000..b03b4f94 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/checkout-provider.tsx @@ -0,0 +1,103 @@ +"use client"; + +import React, { createContext, useContext, useState } from "react"; +import { useForm, useFormContext } from "react-hook-form"; +import { + CheckoutForm, + checkoutFormSchema, +} from "../../../components/checkout/form-schema/checkout-form-schema"; +import { + CartState, + useAuthedAccountMember, + useCart, +} from "@elasticpath/react-shopper-hooks"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Form } from "../../../components/form/Form"; +import { ShippingMethod, useShippingMethod } from "./useShippingMethod"; +import { usePaymentComplete } from "./usePaymentComplete"; + +type CheckoutContext = { + cart?: CartState; + isLoading: boolean; + completePayment: ReturnType; + isCompleting: boolean; + confirmationData: ReturnType["data"]; + shippingMethods: { + options?: ShippingMethod[]; + isLoading: boolean; + }; +}; + +const CheckoutContext = createContext(null); + +type CheckoutProviderProps = { + children?: React.ReactNode; +}; + +export function CheckoutProvider({ children }: CheckoutProviderProps) { + const { state, useClearCart } = useCart(); + + const { mutateAsync: mutateClearCart } = useClearCart(); + + const [confirmationData, setConfirmationData] = + useState["data"]>(undefined); + + const formMethods = useForm({ + reValidateMode: "onChange", + resolver: zodResolver(checkoutFormSchema), + defaultValues: { + sameAsShipping: true, + shippingMethod: "__shipping_standard", + }, + }); + + const { selectedAccountToken } = useAuthedAccountMember(); + + const { data: shippingMethods, isLoading: isShippingMethodsLoading } = + useShippingMethod(); + + const paymentComplete = usePaymentComplete( + { + cartId: state?.id, + accountToken: selectedAccountToken?.token, + }, + { + onSuccess: async (data) => { + setConfirmationData(data); + state?.id && + (await mutateClearCart({ + cartId: state.id, + })); + }, + }, + ); + + return ( +
+ + {children} + +
+ ); +} + +export const useCheckout = () => { + const context = useContext(CheckoutContext); + const form = useFormContext(); + if (context === null) { + throw new Error("useCheckout must be used within a CheckoutProvider"); + } + return { ...context, ...form }; +}; diff --git a/examples/algolia/src/app/(checkout)/checkout/page.tsx b/examples/algolia/src/app/(checkout)/checkout/page.tsx new file mode 100644 index 00000000..9f6d91f3 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/page.tsx @@ -0,0 +1,47 @@ +import { Metadata } from "next"; +import { AccountCheckout } from "./AccountCheckout"; +import { retrieveAccountMemberCredentials } from "../../../lib/retrieve-account-member-credentials"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../lib/cookie-constants"; +import { GuestCheckout } from "./GuestCheckout"; +import { cookies } from "next/headers"; +import { notFound, redirect } from "next/navigation"; +import { COOKIE_PREFIX_KEY } from "../../../lib/resolve-cart-env"; +import { getEpccImplicitClient } from "../../../lib/epcc-implicit-client"; +import { CheckoutProvider } from "./checkout-provider"; +import { CheckoutViews } from "./CheckoutViews"; + +export const metadata: Metadata = { + title: "Checkout", +}; +export default async function CheckoutPage() { + const cookieStore = cookies(); + + const cartCookie = cookieStore.get(`${COOKIE_PREFIX_KEY}_ep_cart`); + const client = getEpccImplicitClient(); + + const cart = await client + .Cart(cartCookie?.value) + .With("items") + .Get(); + + if (!cart) { + notFound(); + } + + if ((cart.included?.items?.length ?? 0) < 1) { + redirect("/cart"); + } + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + return ( + + + {!accountMemberCookie ? : } + + + ); +} diff --git a/examples/algolia/src/app/(checkout)/checkout/usePaymentComplete.tsx b/examples/algolia/src/app/(checkout)/checkout/usePaymentComplete.tsx new file mode 100644 index 00000000..71010803 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/usePaymentComplete.tsx @@ -0,0 +1,129 @@ +import { + useAddCustomItemToCart, + useCheckout as useCheckoutCart, + useCheckoutWithAccount, + usePayments, +} from "@elasticpath/react-shopper-hooks"; +import { useMutation, UseMutationOptions } from "@tanstack/react-query"; +import { CheckoutForm } from "../../../components/checkout/form-schema/checkout-form-schema"; +import { staticDeliveryMethods } from "./useShippingMethod"; +import { + CartItemsResponse, + ConfirmPaymentResponse, + Order, + Resource, +} from "@moltin/sdk"; + +export type UsePaymentCompleteProps = { + cartId: string | undefined; + accountToken?: string; +}; + +export type UsePaymentCompleteReq = { + data: CheckoutForm; +}; + +export function usePaymentComplete( + { cartId, accountToken }: UsePaymentCompleteProps, + options?: UseMutationOptions< + { + order: Resource; + payment: ConfirmPaymentResponse; + cart: CartItemsResponse; + }, + unknown, + UsePaymentCompleteReq + >, +) { + const { mutateAsync: mutatePayment } = usePayments(); + const { mutateAsync: mutateConvertToOrder } = useCheckoutCart(cartId ?? ""); + const { mutateAsync: mutateConvertToOrderAsAccount } = useCheckoutWithAccount( + cartId ?? "", + ); + const { mutateAsync: mutateAddCustomItemToCart } = useAddCustomItemToCart( + cartId ?? "", + ); + + const paymentComplete = useMutation({ + mutationFn: async ({ data }) => { + const { + shippingAddress, + billingAddress, + sameAsShipping, + shippingMethod, + } = data; + + const customerName = `${shippingAddress.first_name} ${shippingAddress.last_name}`; + + const checkoutProps = { + billingAddress: + billingAddress && !sameAsShipping ? billingAddress : shippingAddress, + shippingAddress: shippingAddress, + }; + + /** + * The handling of shipping options is not production ready. + * You must implement your own based on your business needs. + */ + const shippingAmount = + staticDeliveryMethods.find((method) => method.value === shippingMethod) + ?.amount ?? 0; + + /** + * Using a cart custom_item to represent shipping for demo purposes. + */ + const cartInclShipping = await mutateAddCustomItemToCart({ + type: "custom_item", + name: "Shipping", + sku: shippingMethod, + quantity: 1, + price: { + amount: shippingAmount, + includes_tax: true, + }, + }); + + /** + * 1. Convert our cart to an order we can pay + */ + const createdOrder = await ("guest" in data + ? mutateConvertToOrder({ + customer: { + email: data.guest.email, + name: customerName, + }, + ...checkoutProps, + }) + : mutateConvertToOrderAsAccount({ + contact: { + name: data.account.name, + email: data.account.email, + }, + token: accountToken ?? "", + ...checkoutProps, + })); + + /** + * 2. Perform payment against the order + */ + const confirmedPayment = await mutatePayment({ + orderId: createdOrder.data.id, + payment: { + gateway: "manual", + method: "purchase", + }, + }); + + return { + order: createdOrder, + payment: confirmedPayment, + cart: cartInclShipping, + }; + }, + ...options, + }); + + return { + ...paymentComplete, + }; +} diff --git a/examples/algolia/src/app/(checkout)/checkout/useShippingMethod.tsx b/examples/algolia/src/app/(checkout)/checkout/useShippingMethod.tsx new file mode 100644 index 00000000..9703ec2b --- /dev/null +++ b/examples/algolia/src/app/(checkout)/checkout/useShippingMethod.tsx @@ -0,0 +1,39 @@ +import { useQuery } from "@tanstack/react-query"; + +export type ShippingMethod = { + label: string; + value: string; + amount: number; + formatted: string; +}; + +export const staticDeliveryMethods: ShippingMethod[] = [ + { + label: "Standard", + value: "__shipping_standard", + amount: 0, + formatted: "Free", + }, + { + label: "Express", + value: "__shipping_express", + amount: 1200, + formatted: "$12.00", + }, +]; + +export function useShippingMethod() { + const deliveryMethods = useQuery({ + queryKey: ["delivery-methods"], + queryFn: () => { + /** + * Replace these with your own delivery methods. You can also fetch them from the API. + */ + return staticDeliveryMethods; + }, + }); + + return { + ...deliveryMethods, + }; +} diff --git a/examples/algolia/src/app/(checkout)/layout.tsx b/examples/algolia/src/app/(checkout)/layout.tsx new file mode 100644 index 00000000..16f80170 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/layout.tsx @@ -0,0 +1,51 @@ +import { Inter } from "next/font/google"; +import { ReactNode } from "react"; +import { getStoreInitialState } from "../../lib/get-store-initial-state"; +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { Providers } from "../providers"; +import clsx from "clsx"; + +const { SITE_NAME } = process.env; +const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL + ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` + : "http://localhost:3000"; + +export const metadata = { + metadataBase: new URL(baseUrl), + title: { + default: SITE_NAME!, + template: `%s | ${SITE_NAME}`, + }, + robots: { + follow: true, + index: true, + }, +}; + +const inter = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-inter", +}); + +export default async function CheckoutLayout({ + children, +}: { + children: ReactNode; +}) { + const client = getServerSideImplicitClient(); + const initialState = await getStoreInitialState(client); + + return ( + + + {/* headless ui needs this div - https://github.com/tailwindlabs/headlessui/issues/2752#issuecomment-1745272229 */} +
+ +
{children}
+
+
+ + + ); +} diff --git a/examples/algolia/src/app/(checkout)/not-found.tsx b/examples/algolia/src/app/(checkout)/not-found.tsx new file mode 100644 index 00000000..d97d88f4 --- /dev/null +++ b/examples/algolia/src/app/(checkout)/not-found.tsx @@ -0,0 +1,13 @@ +import Link from "next/link"; +export default function NotFound() { + return ( +
+ + 404 - The page could not be found. + + + Back to home + +
+ ); +} diff --git a/examples/algolia/src/app/about/page.tsx b/examples/algolia/src/app/(store)/about/page.tsx similarity index 56% rename from examples/algolia/src/app/about/page.tsx rename to examples/algolia/src/app/(store)/about/page.tsx index edda2df8..fc900ed0 100644 --- a/examples/algolia/src/app/about/page.tsx +++ b/examples/algolia/src/app/(store)/about/page.tsx @@ -1,4 +1,4 @@ -import Blurb from "../../components/shared/blurb"; +import Blurb from "../../../components/shared/blurb"; export default function About() { return ; diff --git a/examples/algolia/src/app/(store)/account/AccountNavigation.tsx b/examples/algolia/src/app/(store)/account/AccountNavigation.tsx new file mode 100644 index 00000000..f3474312 --- /dev/null +++ b/examples/algolia/src/app/(store)/account/AccountNavigation.tsx @@ -0,0 +1,54 @@ +"use client"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { Button } from "../../../components/button/Button"; +import { logout } from "../../(auth)/actions"; +import { useTransition } from "react"; + +export function AccountNavigation() { + const pathname = usePathname(); + const [isPending, startTransition] = useTransition(); + + return ( + + ); +} diff --git a/examples/algolia/src/app/(store)/account/addresses/[addressId]/page.tsx b/examples/algolia/src/app/(store)/account/addresses/[addressId]/page.tsx new file mode 100644 index 00000000..677ca2f4 --- /dev/null +++ b/examples/algolia/src/app/(store)/account/addresses/[addressId]/page.tsx @@ -0,0 +1,236 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../../lib/retrieve-account-member-credentials"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../../lib/cookie-constants"; +import { getServerSideImplicitClient } from "../../../../../lib/epcc-server-side-implicit-client"; +import { Label } from "../../../../../components/label/Label"; +import { Input } from "../../../../../components/input/Input"; +import { updateAddress } from "../actions"; +import { FormStatusButton } from "../../../../../components/button/FormStatusButton"; +import { Button } from "../../../../../components/button/Button"; +import Link from "next/link"; +import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../../../components/select/Select"; +import React from "react"; +import { countries as staticCountries } from "../../../../../lib/all-countries"; +import { Separator } from "../../../../../components/separator/Separator"; + +export const dynamic = "force-dynamic"; + +export default async function Address({ + params, +}: { + params: { addressId: string }; +}) { + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + const { addressId } = params; + + const activeAccount = getSelectedAccount(accountMemberCookie); + + const client = getServerSideImplicitClient(); + + const address = await client.AccountAddresses.Get({ + account: activeAccount.account_id, + address: addressId, + token: activeAccount.token, + }); + + const addressData = address.data; + + const countries = staticCountries; + + return ( +
+
+ +
+ +
+

Edit Address

+
+
+ +
+

+ + +

+
+
+

+ + +

+

+ + +

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + Save changes + +
+
+
+
+ ); +} diff --git a/examples/algolia/src/app/(store)/account/addresses/actions.ts b/examples/algolia/src/app/(store)/account/addresses/actions.ts new file mode 100644 index 00000000..37dcd0fe --- /dev/null +++ b/examples/algolia/src/app/(store)/account/addresses/actions.ts @@ -0,0 +1,200 @@ +"use server"; + +import { z } from "zod"; +import { cookies } from "next/headers"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../lib/retrieve-account-member-credentials"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../lib/cookie-constants"; +import { revalidatePath } from "next/cache"; +import { shippingAddressSchema } from "../../../../components/checkout/form-schema/checkout-form-schema"; +import { AccountAddress, Resource } from "@moltin/sdk"; +import { redirect } from "next/navigation"; + +const deleteAddressSchema = z.object({ + addressId: z.string(), +}); + +const updateAddressSchema = shippingAddressSchema.merge( + z.object({ + name: z.string(), + addressId: z.string(), + line_2: z + .string() + .optional() + .transform((e) => (e === "" ? undefined : e)), + }), +); + +const addAddressSchema = shippingAddressSchema.merge( + z.object({ + name: z.string(), + line_2: z + .string() + .optional() + .transform((e) => (e === "" ? undefined : e)), + }), +); + +export async function deleteAddress(formData: FormData) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = deleteAddressSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + throw new Error("Invalid address id"); + } + + const accountMemberCreds = retrieveAccountMemberCredentials( + cookies(), + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCreds) { + throw new Error("Account member credentials not found"); + } + + const { addressId } = validatedFormData.data; + const selectedAccount = getSelectedAccount(accountMemberCreds); + + try { + // TODO fix the sdk typing for this endpoint + // should be able to include the token in the request + await client.request.send( + `/accounts/${selectedAccount.account_id}/addresses/${addressId}`, + "DELETE", + null, + undefined, + client, + undefined, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + revalidatePath("/accounts/addresses"); + } catch (error) { + console.error(error); + throw new Error("Error deleting address"); + } +} + +export async function updateAddress(formData: FormData) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = updateAddressSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + console.error(JSON.stringify(validatedFormData.error)); + throw new Error("Invalid address submission"); + } + + const accountMemberCreds = retrieveAccountMemberCredentials( + cookies(), + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCreds) { + throw new Error("Account member credentials not found"); + } + + const selectedAccount = getSelectedAccount(accountMemberCreds); + + const { addressId, ...addressData } = validatedFormData.data; + + const body = { + data: { + type: "address", + id: addressId, + ...addressData, + }, + }; + + try { + // TODO fix the sdk typing for this endpoint + // should be able to include the token in the request + await client.request.send( + `/accounts/${selectedAccount.account_id}/addresses/${addressId}`, + "PUT", + body, + undefined, + client, + false, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + revalidatePath("/accounts/addresses"); + } catch (error) { + console.error(error); + throw new Error("Error updating address"); + } +} + +export async function addAddress(formData: FormData) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = addAddressSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + console.error(JSON.stringify(validatedFormData.error)); + throw new Error("Invalid address submission"); + } + + const accountMemberCreds = retrieveAccountMemberCredentials( + cookies(), + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCreds) { + throw new Error("Account member credentials not found"); + } + + const selectedAccount = getSelectedAccount(accountMemberCreds); + + const { ...addressData } = validatedFormData.data; + + const body = { + data: { + type: "address", + ...addressData, + }, + }; + + let redirectUrl: string | undefined = undefined; + try { + // TODO fix the sdk typing for this endpoint + // should be able to include the token in the request + const result = (await client.request.send( + `/accounts/${selectedAccount.account_id}/addresses`, + "POST", + body, + undefined, + client, + false, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + )) as Resource; + + redirectUrl = `/account/addresses/${result.data.id}`; + } catch (error) { + console.error(error); + throw new Error("Error adding address"); + } + + revalidatePath("/account/addresses"); + redirect(redirectUrl); +} diff --git a/examples/algolia/src/app/(store)/account/addresses/add/page.tsx b/examples/algolia/src/app/(store)/account/addresses/add/page.tsx new file mode 100644 index 00000000..11d5fef1 --- /dev/null +++ b/examples/algolia/src/app/(store)/account/addresses/add/page.tsx @@ -0,0 +1,209 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { retrieveAccountMemberCredentials } from "../../../../../lib/retrieve-account-member-credentials"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../../lib/cookie-constants"; +import { Label } from "../../../../../components/label/Label"; +import { Input } from "../../../../../components/input/Input"; +import { addAddress } from "../actions"; +import { FormStatusButton } from "../../../../../components/button/FormStatusButton"; +import { Button } from "../../../../../components/button/Button"; +import Link from "next/link"; +import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../../../components/select/Select"; +import React from "react"; +import { countries as staticCountries } from "../../../../../lib/all-countries"; +import { Separator } from "../../../../../components/separator/Separator"; + +export const dynamic = "force-dynamic"; + +export default async function AddAddress({ + params, +}: { + params: { addressId: string }; +}) { + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + + const { addressId } = params; + + const countries = staticCountries; + + return ( +
+
+ +
+ +
+

Add Address

+
+
+ +
+

+ + +

+
+
+

+ + +

+

+ + +

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + Save changes + +
+
+
+
+ ); +} diff --git a/examples/algolia/src/app/(store)/account/addresses/page.tsx b/examples/algolia/src/app/(store)/account/addresses/page.tsx new file mode 100644 index 00000000..221e0688 --- /dev/null +++ b/examples/algolia/src/app/(store)/account/addresses/page.tsx @@ -0,0 +1,107 @@ +import { + PencilSquareIcon, + PlusIcon, + TrashIcon, +} from "@heroicons/react/24/outline"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { cookies } from "next/headers"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../lib/cookie-constants"; +import { redirect } from "next/navigation"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../lib/retrieve-account-member-credentials"; +import Link from "next/link"; +import { deleteAddress } from "./actions"; +import { FormStatusButton } from "../../../../components/button/FormStatusButton"; +import { Button } from "../../../../components/button/Button"; +import { Separator } from "../../../../components/separator/Separator"; +import React from "react"; + +export const dynamic = "force-dynamic"; + +export default async function Addresses() { + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + + const selectedAccount = getSelectedAccount(accountMemberCookie); + + const client = getServerSideImplicitClient(); + + const addresses = await client.AccountAddresses.All({ + account: selectedAccount.account_id, + token: selectedAccount.token, + }); + + return ( +
+
+

Your info

+
    + {addresses.data.map((address) => ( +
  • +
    +
    +

    + {address.name} +

    +
    +
    +

    + {address.first_name} {address.last_name} +

    +

    {address.line_1}

    +

    {address.postcode}

    +
    +
    +
    + +
    + + } + > + Delete + +
    +
    +
  • + ))} +
+
+ +
+ +
+
+ ); +} diff --git a/examples/algolia/src/app/(store)/account/breadcrumb.tsx b/examples/algolia/src/app/(store)/account/breadcrumb.tsx new file mode 100644 index 00000000..1ca7add5 --- /dev/null +++ b/examples/algolia/src/app/(store)/account/breadcrumb.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { HomeIcon } from "@heroicons/react/20/solid"; +import { usePathname } from "next/navigation"; +import Link from "next/link"; +import clsx from "clsx"; + +interface BreadcrumbItem { + label: string; + href?: string; + current: boolean; +} + +export function Breadcrumb() { + const pathname = usePathname(); + + const pathnameParts = pathname.split("/").filter(Boolean); + + // Function to convert pathname to breadcrumb items + const getBreadcrumbItems = (pathname: string): BreadcrumbItem[] => { + const pathSegments = pathname + .split("/") + .filter((segment) => segment !== ""); + + return pathSegments.map((segment, index) => { + const href = + segment === "account" + ? undefined + : `/${pathSegments.slice(0, index + 1).join("/")}`; + return { + label: segment, + href, + current: index === pathSegments.length - 1, // Set current to true for the last segment + }; + }); + }; + + const breadcrumbItems = getBreadcrumbItems(pathname); + + return ( + + ); +} diff --git a/examples/algolia/src/app/(store)/account/layout.tsx b/examples/algolia/src/app/(store)/account/layout.tsx new file mode 100644 index 00000000..2bc085fe --- /dev/null +++ b/examples/algolia/src/app/(store)/account/layout.tsx @@ -0,0 +1,17 @@ +import { ReactNode } from "react"; +import { AccountNavigation } from "./AccountNavigation"; + +export default function AccountLayout({ children }: { children: ReactNode }) { + return ( +
+
+
+
+ +
+
{children}
+
+
+
+ ); +} diff --git a/examples/algolia/src/app/(store)/account/orders/OrderItem.tsx b/examples/algolia/src/app/(store)/account/orders/OrderItem.tsx new file mode 100644 index 00000000..2a8eed64 --- /dev/null +++ b/examples/algolia/src/app/(store)/account/orders/OrderItem.tsx @@ -0,0 +1,70 @@ +import { ReactNode } from "react"; +import { Order, OrderItem as OrderItemType } from "@moltin/sdk"; +import { ProductThumbnail } from "./[orderId]/ProductThumbnail"; +import Link from "next/link"; +import { formatIsoDateString } from "../../../../lib/format-iso-date-string"; + +export type OrderItemProps = { + children?: ReactNode; + order: Order; + orderItems: OrderItemType[]; + imageUrl?: string; +}; + +export function OrderItem({ children, order, orderItems }: OrderItemProps) { + // Sorted order items are used to determine which image to show + // showing the most expensive item's image + const sortedOrderItems = orderItems.sort( + (a, b) => b.unit_price.amount - a.unit_price.amount, + ); + return ( +
+
+ + + +
+
+ + Order # {order.external_ref ?? order.id} + + +

+ {formatOrderItemsTitle(sortedOrderItems)} +

+ + {children} +
+
+ + {order.meta.display_price.with_tax.formatted} +
+
+ ); +} + +function formatOrderItemsTitle(orderItems: OrderItemType[]): string { + if (orderItems.length === 0) { + return "No items in the order"; + } + + if (orderItems.length === 1) { + return orderItems[0].name; + } + + const firstTwoItems = orderItems.slice(0, 2).map((item) => item.name); + const remainingItemCount = orderItems.length - 2; + + if (remainingItemCount === 0) { + return `${firstTwoItems.join(" and ")} in the order`; + } + + return `${firstTwoItems.join(", ")} and ${remainingItemCount} other item${ + remainingItemCount > 1 ? "s" : "" + }`; +} diff --git a/examples/algolia/src/app/(store)/account/orders/OrderItemWithDetails.tsx b/examples/algolia/src/app/(store)/account/orders/OrderItemWithDetails.tsx new file mode 100644 index 00000000..4781054b --- /dev/null +++ b/examples/algolia/src/app/(store)/account/orders/OrderItemWithDetails.tsx @@ -0,0 +1,32 @@ +import { OrderItem, OrderItemProps } from "./OrderItem"; +import { formatIsoDateString } from "../../../../lib/format-iso-date-string"; + +export function OrderItemWithDetails(props: Omit) { + const sortedOrderItems = props.orderItems.sort( + (a, b) => b.unit_price.amount - a.unit_price.amount, + ); + + return ( + +
+
    + {sortedOrderItems.map((item) => ( +
  • + {item.quantity} × {item.name} +
  • + ))} +
+
+
+ Ordered + +
+
+ + ); +} diff --git a/examples/algolia/src/app/(store)/account/orders/[orderId]/OrderLineItem.tsx b/examples/algolia/src/app/(store)/account/orders/[orderId]/OrderLineItem.tsx new file mode 100644 index 00000000..350edf82 --- /dev/null +++ b/examples/algolia/src/app/(store)/account/orders/[orderId]/OrderLineItem.tsx @@ -0,0 +1,42 @@ +import { ProductThumbnail } from "./ProductThumbnail"; +import { OrderItem } from "@moltin/sdk"; +import Link from "next/link"; + +export function OrderLineItem({ orderItem }: { orderItem: OrderItem }) { + return ( +
+
+ + + +
+
+
+ +

+ {orderItem.name} +

+ + + Quantity: {orderItem.quantity} + +
+
+ + {orderItem.meta?.display_price?.with_tax?.value.formatted} + + {orderItem.meta?.display_price?.without_discount?.value.amount && + orderItem.meta?.display_price?.without_discount?.value.amount !== + orderItem.meta?.display_price?.with_tax?.value.amount && ( + + { + orderItem.meta?.display_price?.without_discount?.value + .formatted + } + + )} +
+
+
+ ); +} diff --git a/examples/algolia/src/app/(store)/account/orders/[orderId]/ProductThumbnail.tsx b/examples/algolia/src/app/(store)/account/orders/[orderId]/ProductThumbnail.tsx new file mode 100644 index 00000000..9e8f8d50 --- /dev/null +++ b/examples/algolia/src/app/(store)/account/orders/[orderId]/ProductThumbnail.tsx @@ -0,0 +1,22 @@ +"use client"; +import Image from "next/image"; +import { useProduct } from "@elasticpath/react-shopper-hooks"; + +const gray1pxBase64 = + "data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=="; + +export function ProductThumbnail({ productId }: { productId: string }) { + const { data, included } = useProduct({ productId }); + + const imageHref = included?.main_images?.[0]?.link.href; + const title = data?.attributes?.name ?? "Loading..."; + return ( + {title} + ); +} diff --git a/examples/algolia/src/app/(store)/account/orders/[orderId]/page.tsx b/examples/algolia/src/app/(store)/account/orders/[orderId]/page.tsx new file mode 100644 index 00000000..9b390478 --- /dev/null +++ b/examples/algolia/src/app/(store)/account/orders/[orderId]/page.tsx @@ -0,0 +1,206 @@ +import { cookies } from "next/headers"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../../lib/cookie-constants"; +import { notFound, redirect } from "next/navigation"; +import { getServerSideImplicitClient } from "../../../../../lib/epcc-server-side-implicit-client"; +import { + Order, + OrderIncluded, + OrderItem, + RelationshipToMany, +} from "@moltin/sdk"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../../lib/retrieve-account-member-credentials"; +import { Button } from "../../../../../components/button/Button"; +import { ArrowLeftIcon } from "@heroicons/react/24/outline"; +import Link from "next/link"; +import { formatIsoDateString } from "../../../../../lib/format-iso-date-string"; +import { OrderLineItem } from "./OrderLineItem"; + +export const dynamic = "force-dynamic"; + +export default async function Order({ + params, +}: { + params: { orderId: string }; +}) { + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + + const selectedAccount = getSelectedAccount(accountMemberCookie); + + const client = getServerSideImplicitClient(); + + let result: Awaited> | undefined = + undefined; + try { + result = await client.request.send( + `/orders/${params.orderId}?include=items`, + "GET", + null, + undefined, + client, + undefined, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + } catch (e: any) { + if ( + "errors" in e && + (e.errors as any)[0].detail === "The order does not exist" + ) { + notFound(); + } + throw e; + } + + const shopperOrder = result!.included + ? resolveShopperOrder(result!.data, result!.included) + : { raw: result!.data, items: [] }; + + const shippingAddress = shopperOrder.raw.shipping_address; + + const productItems = shopperOrder.items.filter( + (item) => + item.unit_price.amount >= 0 && !item.sku.startsWith("__shipping_"), + ); + const shippingItem = shopperOrder.items.find((item) => + item.sku.startsWith("__shipping_"), + ); + + return ( +
+
+ +
+
+
+
+

Order # {shopperOrder.raw.id}

+ +
+
+
+
+ Shipping address +

+ {shippingAddress.first_name} {shippingAddress.last_name} +
+ {shippingAddress.line_1} +
+ {shippingAddress.city ?? shippingAddress.county},{" "} + {shippingAddress.postcode} {shippingAddress.country} +

+
+
+ Shipping status + {shopperOrder.raw.shipping} +
+
+ Payment status + {shopperOrder.raw.payment} +
+
+
+
    + {productItems.map((item) => ( +
  • + +
  • + ))} +
+
+
+
+
+ Subtotal + + {shopperOrder.raw.meta.display_price.without_tax.formatted} + +
+
+ Shipping + + {shippingItem?.meta?.display_price?.with_tax?.value.formatted ?? + shopperOrder.raw.meta.display_price.shipping.formatted} + +
+ {shopperOrder.raw.meta.display_price.discount.amount < 0 && ( +
+ Discount + + {shopperOrder.raw.meta.display_price.discount.formatted} + +
+ )} +
+ Sales Tax + + {shopperOrder.raw.meta.display_price.tax.formatted} + +
+
+
+
+ Total + + {shopperOrder.raw.meta.display_price.with_tax.formatted} + +
+
+
+ ); +} + +function resolveOrderItemsFromRelationship( + itemRelationships: RelationshipToMany<"item">["data"], + itemMap: Record, +): OrderItem[] { + return itemRelationships.reduce((orderItems, itemRel) => { + const includedItem: OrderItem | undefined = itemMap[itemRel.id]; + return [...orderItems, ...(includedItem && [includedItem])]; + }, [] as OrderItem[]); +} + +function resolveShopperOrder( + order: Order, + included: NonNullable, +): { raw: Order; items: OrderItem[] } { + // Create a map of included items by their id + const itemMap = included.items + ? included.items.reduce( + (acc, item) => { + return { ...acc, [item.id]: item }; + }, + {} as Record, + ) + : {}; + + // Map the items in the data array to their corresponding included items + const orderItems = order.relationships?.items?.data + ? resolveOrderItemsFromRelationship(order.relationships.items.data, itemMap) + : []; + + return { + raw: order, + items: orderItems, + }; +} diff --git a/examples/algolia/src/app/(store)/account/orders/page.tsx b/examples/algolia/src/app/(store)/account/orders/page.tsx new file mode 100644 index 00000000..d6a72acc --- /dev/null +++ b/examples/algolia/src/app/(store)/account/orders/page.tsx @@ -0,0 +1,128 @@ +import { cookies } from "next/headers"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../lib/cookie-constants"; +import { redirect } from "next/navigation"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { + Order, + OrderItem, + RelationshipToMany, + ResourcePage, +} from "@moltin/sdk"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../lib/retrieve-account-member-credentials"; +import { ResourcePagination } from "../../../../components/pagination/ResourcePagination"; +import { DEFAULT_PAGINATION_LIMIT } from "../../../../lib/constants"; +import { OrderItemWithDetails } from "./OrderItemWithDetails"; + +export const dynamic = "force-dynamic"; + +export default async function Orders({ + searchParams, +}: { + searchParams?: { + limit?: string; + offset?: string; + page?: string; + }; +}) { + const currentPage = Number(searchParams?.page) || 1; + const limit = Number(searchParams?.limit) || DEFAULT_PAGINATION_LIMIT; + const offset = Number(searchParams?.offset) || 0; + + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + + const selectedAccount = getSelectedAccount(accountMemberCookie); + + const client = getServerSideImplicitClient(); + + const result: Awaited> = + await client.request.send( + `/orders?include=items&page[limit]=${limit}&page[offset]=${offset}`, + "GET", + null, + undefined, + client, + undefined, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + const mappedOrders = result.included + ? resolveShopperOrder(result.data, result.included) + : []; + + const totalPages = Math.ceil(result.meta.results.total / limit); + + return ( +
+
+

Order history

+
+
+
    + {mappedOrders.map(({ raw: order, items }) => ( +
  • + +
  • + ))} +
+
+
+ +
+
+ ); +} + +function resolveOrderItemsFromRelationship( + itemRelationships: RelationshipToMany<"item">["data"], + itemMap: Record, +): OrderItem[] { + return itemRelationships.reduce((orderItems, itemRel) => { + const includedItem: OrderItem | undefined = itemMap[itemRel.id]; + return [...orderItems, ...(includedItem && [includedItem])]; + }, [] as OrderItem[]); +} + +function resolveShopperOrder( + data: Order[], + included: NonNullable< + ResourcePage["included"] + >, +): { raw: Order; items: OrderItem[] }[] { + // Create a map of included items by their id + const itemMap = included.items.reduce( + (acc, item) => { + return { ...acc, [item.id]: item }; + }, + {} as Record, + ); + + // Map the items in the data array to their corresponding included items + return data.map((order) => { + const orderItems = order.relationships?.items?.data + ? resolveOrderItemsFromRelationship( + order.relationships.items.data, + itemMap, + ) + : []; + + return { + raw: order, + items: orderItems, + }; + }); +} diff --git a/examples/algolia/src/app/(store)/account/summary/YourInfoForm.tsx b/examples/algolia/src/app/(store)/account/summary/YourInfoForm.tsx new file mode 100644 index 00000000..9fb96377 --- /dev/null +++ b/examples/algolia/src/app/(store)/account/summary/YourInfoForm.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { updateAccount } from "./actions"; +import { Label } from "../../../../components/label/Label"; +import { Input } from "../../../../components/input/Input"; +import { FormStatusButton } from "../../../../components/button/FormStatusButton"; +import { useState } from "react"; + +export function YourInfoForm({ + accountId, + defaultValues, +}: { + accountId: string; + defaultValues?: { name?: string; email?: string }; +}) { + const [error, setError] = useState(undefined); + + async function updateAccountAction(formData: FormData) { + const result = await updateAccount(formData); + + if (result && "error" in result) { + setError(result.error); + } + } + + return ( +
+
+ Your information +

+ + +

+

+ + +

+ {error && {error}} +
+
+ Save changes +
+ +
+ ); +} diff --git a/examples/algolia/src/app/(store)/account/summary/actions.ts b/examples/algolia/src/app/(store)/account/summary/actions.ts new file mode 100644 index 00000000..b2a923ea --- /dev/null +++ b/examples/algolia/src/app/(store)/account/summary/actions.ts @@ -0,0 +1,220 @@ +"use server"; + +import { z } from "zod"; +import { cookies } from "next/headers"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../lib/retrieve-account-member-credentials"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../lib/cookie-constants"; +import { revalidatePath } from "next/cache"; +import { getServerSideCredentialsClient } from "../../../../lib/epcc-server-side-credentials-client"; +import { getErrorMessage } from "../../../../lib/get-error-message"; + +const updateAccountSchema = z.object({ + id: z.string(), + name: z.string(), +}); + +/** + * TODO request not working for implicit token + EP account management token + * @param formData + */ +export async function updateAccount(formData: FormData) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = updateAccountSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + console.error(JSON.stringify(validatedFormData.error)); + throw new Error("Invalid account submission"); + } + + const accountMemberCreds = retrieveAccountMemberCredentials( + cookies(), + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCreds) { + throw new Error("Account member credentials not found"); + } + + const selectedAccount = getSelectedAccount(accountMemberCreds); + + const { name, id } = validatedFormData.data; + + const body = { + data: { + type: "account", + name, + legal_name: name, + }, + }; + + try { + // TODO fix the sdk typing for this endpoint + // should be able to include the token in the request + await client.request.send( + `/accounts/${id}`, + "PUT", + body, + undefined, + client, + false, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + revalidatePath("/accounts/summary"); + } catch (error) { + console.error(getErrorMessage(error)); + return { + error: getErrorMessage(error), + }; + } + + return; +} + +const updateUserAuthenticationPasswordProfileSchema = z.object({ + username: z.string(), + currentPassword: z.string().optional(), + newPassword: z.string().optional(), +}); + +const PASSWORD_PROFILE_ID = process.env.NEXT_PUBLIC_PASSWORD_PROFILE_ID!; +const AUTHENTICATION_REALM_ID = + process.env.NEXT_PUBLIC_AUTHENTICATION_REALM_ID!; + +export async function updateUserAuthenticationPasswordProfile( + formData: FormData, +) { + const client = getServerSideImplicitClient(); + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = + updateUserAuthenticationPasswordProfileSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + console.error(JSON.stringify(validatedFormData.error)); + throw new Error("Invalid submission"); + } + + const accountMemberCreds = retrieveAccountMemberCredentials( + cookies(), + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCreds) { + throw new Error("Account member credentials not found"); + } + + const { username, newPassword, currentPassword } = validatedFormData.data; + + // Re auth the user to check the current password is correct + const reAuthResult = await client.AccountMembers.GenerateAccountToken({ + type: "account_management_authentication_token", + authentication_mechanism: "password", + password_profile_id: PASSWORD_PROFILE_ID, + username, + password: currentPassword, + }); + + const reAuthedSelectedAccount = reAuthResult.data.find( + (entry) => entry.account_id === accountMemberCreds.selected, + ); + + if (!reAuthedSelectedAccount) { + throw new Error("Error re-authenticating user"); + } + + const credsClient = getServerSideCredentialsClient(); + const userAuthenticationPasswordProfileInfoResult = + await credsClient.UserAuthenticationPasswordProfile.All( + AUTHENTICATION_REALM_ID, + accountMemberCreds.accountMemberId, + ); + + const userAuthenticationPasswordProfileInfo = + userAuthenticationPasswordProfileInfoResult.data.find( + (entry) => entry.password_profile_id === PASSWORD_PROFILE_ID, + ); + + if (!userAuthenticationPasswordProfileInfo) { + throw new Error( + "User authentication password profile info not found for password profile", + ); + } + + const body = { + data: { + type: "user_authentication_password_profile_info", + id: userAuthenticationPasswordProfileInfo.id, + password_profile_id: PASSWORD_PROFILE_ID, + ...(username && { username }), + ...(newPassword && { password: newPassword }), + }, + }; + + try { + // TODO fix the sdk typing for this endpoint + // should be able to include the token in the request + await client.request.send( + `/authentication-realms/${AUTHENTICATION_REALM_ID}/user-authentication-info/${accountMemberCreds.accountMemberId}/user-authentication-password-profile-info/${userAuthenticationPasswordProfileInfo.id}`, + "PUT", + body, + undefined, + client, + false, + "v2", + { + "EP-Account-Management-Authentication-Token": + reAuthedSelectedAccount.token, + }, + ); + + revalidatePath("/accounts"); + } catch (error) { + console.error(error); + throw new Error("Error updating account"); + } +} + +// async function getOneTimePasswordToken( +// client: Moltin, +// username: string, +// ): Promise { +// const response = await client.OneTimePasswordTokenRequest.Create( +// AUTHENTICATION_REALM_ID, +// PASSWORD_PROFILE_ID, +// { +// type: "one_time_password_token_request", +// username, +// purpose: "reset_password", +// }, +// ); +// +// const result2 = await client.request.send( +// `/authentication-realms/${AUTHENTICATION_REALM_ID}/password-profiles/${PASSWORD_PROFILE_ID}/one-time-password-token-request`, +// "POST", +// { +// data: { +// type: "one_time_password_token_request", +// username, +// purpose: "reset_password", +// }, +// }, +// undefined, +// client, +// false, +// "v2", +// ); +// +// return response; +// } diff --git a/examples/algolia/src/app/(store)/account/summary/page.tsx b/examples/algolia/src/app/(store)/account/summary/page.tsx new file mode 100644 index 00000000..89825480 --- /dev/null +++ b/examples/algolia/src/app/(store)/account/summary/page.tsx @@ -0,0 +1,113 @@ +import { cookies } from "next/headers"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../../lib/cookie-constants"; +import { redirect } from "next/navigation"; +import { + getSelectedAccount, + retrieveAccountMemberCredentials, +} from "../../../../lib/retrieve-account-member-credentials"; +import { Label } from "../../../../components/label/Label"; +import { Input } from "../../../../components/input/Input"; +import { FormStatusButton } from "../../../../components/button/FormStatusButton"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { updateUserAuthenticationPasswordProfile } from "./actions"; +import { YourInfoForm } from "./YourInfoForm"; + +export const dynamic = "force-dynamic"; + +export default async function AccountSummary() { + const cookieStore = cookies(); + + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return redirect("/login"); + } + + const client = getServerSideImplicitClient(); + + const selectedAccount = getSelectedAccount(accountMemberCookie); + + const account: Awaited> = + await client.request.send( + `/accounts/${selectedAccount.account_id}`, + "GET", + null, + undefined, + client, + undefined, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + const accountMember: Awaited> = + await client.request.send( + `/account-members/${accountMemberCookie.accountMemberId}`, + "GET", + null, + undefined, + client, + undefined, + "v2", + { + "EP-Account-Management-Authentication-Token": selectedAccount.token, + }, + ); + + return ( +
+
+

Your info

+ +
+
+

Change Password

+
+
+ Password information +

+ + +

+

+ + +

+

+ + +

+
+ +
+ + Save changes + +
+
+
+
+ ); +} diff --git a/examples/algolia/src/app/(store)/cart/CartItem.tsx b/examples/algolia/src/app/(store)/cart/CartItem.tsx new file mode 100644 index 00000000..30fd205d --- /dev/null +++ b/examples/algolia/src/app/(store)/cart/CartItem.tsx @@ -0,0 +1,62 @@ +"use client"; +import { useCart } from "@elasticpath/react-shopper-hooks"; +import { ProductThumbnail } from "../account/orders/[orderId]/ProductThumbnail"; +import { NumberInput } from "../../../components/number-input/NumberInput"; +import Link from "next/link"; +import { CartItem as CartItemType } from "@moltin/sdk"; +import { LoadingDots } from "../../../components/LoadingDots"; + +export type CartItemProps = { + item: CartItemType; +}; + +export function CartItem({ item }: CartItemProps) { + const { useScopedRemoveCartItem } = useCart(); + const { mutate, isPending } = useScopedRemoveCartItem(); + + return ( +
+
+ +
+
+
+
+ + {item.name} + + + Quantity: {item.quantity} + +
+
+ + {item.meta.display_price.with_tax.value.formatted} + + {item.meta.display_price.without_discount?.value.amount && + item.meta.display_price.without_discount?.value.amount !== + item.meta.display_price.with_tax.value.amount && ( + + {item.meta.display_price.without_discount?.value.formatted} + + )} +
+
+
+ + {isPending ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/examples/algolia/src/app/(store)/cart/CartItemWide.tsx b/examples/algolia/src/app/(store)/cart/CartItemWide.tsx new file mode 100644 index 00000000..935733a7 --- /dev/null +++ b/examples/algolia/src/app/(store)/cart/CartItemWide.tsx @@ -0,0 +1,62 @@ +"use client"; +import { useCart } from "@elasticpath/react-shopper-hooks"; +import { ProductThumbnail } from "../account/orders/[orderId]/ProductThumbnail"; +import Link from "next/link"; +import { NumberInput } from "../../../components/number-input/NumberInput"; +import { CartItemProps } from "./CartItem"; +import { LoadingDots } from "../../../components/LoadingDots"; + +export function CartItemWide({ item }: CartItemProps) { + const { useScopedRemoveCartItem } = useCart(); + const { mutate, isPending } = useScopedRemoveCartItem(); + + return ( +
+ {/* Thumbnail */} +
+ +
+ {/* Details */} +
+
+
+ + + {item.name} + + + + Quantity: {item.quantity} + +
+
+ + {isPending ? ( + + ) : ( + + )} +
+
+
+
+ + {item.meta.display_price.with_tax.value.formatted} + + {item.meta.display_price.without_discount?.value.amount && + item.meta.display_price.without_discount?.value.amount !== + item.meta.display_price.with_tax.value.amount && ( + + {item.meta.display_price.without_discount?.value.formatted} + + )} +
+
+ ); +} diff --git a/examples/algolia/src/app/(store)/cart/CartSidebar.tsx b/examples/algolia/src/app/(store)/cart/CartSidebar.tsx new file mode 100644 index 00000000..602471ec --- /dev/null +++ b/examples/algolia/src/app/(store)/cart/CartSidebar.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useCart } from "@elasticpath/react-shopper-hooks"; +import { Separator } from "../../../components/separator/Separator"; +import { CartDiscounts } from "../../../components/cart/CartDiscounts"; +import * as React from "react"; +import { + ItemSidebarPromotions, + ItemSidebarSumTotal, + ItemSidebarTotals, + ItemSidebarTotalsDiscount, + ItemSidebarTotalsSubTotal, + ItemSidebarTotalsTax, +} from "../../../components/checkout-sidebar/ItemSidebar"; + +export function CartSidebar() { + const { state } = useCart(); + + if (!state) { + return null; + } + + const { meta } = state; + + return ( +
+ + + + {/* Totals */} + + +
+ Shipping + Calculated at checkout +
+ + +
+ + {/* Sum Total */} + +
+ ); +} diff --git a/examples/algolia/src/app/(store)/cart/CartView.tsx b/examples/algolia/src/app/(store)/cart/CartView.tsx new file mode 100644 index 00000000..e82e5110 --- /dev/null +++ b/examples/algolia/src/app/(store)/cart/CartView.tsx @@ -0,0 +1,52 @@ +"use client"; +import { YourBag } from "./YourBag"; +import { CartSidebar } from "./CartSidebar"; +import { Button } from "../../../components/button/Button"; +import Link from "next/link"; +import { LockClosedIcon } from "@heroicons/react/24/solid"; +import { useCart } from "@elasticpath/react-shopper-hooks"; + +export function CartView() { + const { state } = useCart(); + return ( + <> + {state?.items.length && state.items.length > 0 ? ( +
+ {/* Main Content */} +
+
+

Your Bag

+ {/* Cart Items */} + +
+
+ {/* Sidebar */} +
+ + +
+
+ ) : ( + <> + {/* Empty Cart */} +
+

+ Empty Cart +

+

Your cart is empty

+
+ +
+
+ + )} + + ); +} diff --git a/examples/algolia/src/app/(store)/cart/YourBag.tsx b/examples/algolia/src/app/(store)/cart/YourBag.tsx new file mode 100644 index 00000000..97266e5d --- /dev/null +++ b/examples/algolia/src/app/(store)/cart/YourBag.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { CartItemWide } from "./CartItemWide"; +import { useCart } from "@elasticpath/react-shopper-hooks"; + +export function YourBag() { + const { state } = useCart(); + + return ( +
    + {state?.items.map((item) => { + return ( +
  • + +
  • + ); + })} +
+ ); +} diff --git a/examples/algolia/src/app/(store)/cart/page.tsx b/examples/algolia/src/app/(store)/cart/page.tsx new file mode 100644 index 00000000..de5e40bc --- /dev/null +++ b/examples/algolia/src/app/(store)/cart/page.tsx @@ -0,0 +1,5 @@ +import { CartView } from "./CartView"; + +export default async function CartPage() { + return ; +} diff --git a/examples/simple/src/app/faq/page.tsx b/examples/algolia/src/app/(store)/faq/page.tsx similarity index 55% rename from examples/simple/src/app/faq/page.tsx rename to examples/algolia/src/app/(store)/faq/page.tsx index 7aac9fba..786734bc 100644 --- a/examples/simple/src/app/faq/page.tsx +++ b/examples/algolia/src/app/(store)/faq/page.tsx @@ -1,4 +1,4 @@ -import Blurb from "../../components/shared/blurb"; +import Blurb from "../../../components/shared/blurb"; export default function FAQ() { return ; diff --git a/examples/algolia/src/app/(store)/layout.tsx b/examples/algolia/src/app/(store)/layout.tsx new file mode 100644 index 00000000..26377c29 --- /dev/null +++ b/examples/algolia/src/app/(store)/layout.tsx @@ -0,0 +1,64 @@ +import { ReactNode, Suspense } from "react"; +import { Inter } from "next/font/google"; +import { getStoreInitialState } from "../../lib/get-store-initial-state"; +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { Providers } from "../providers"; +import Header from "../../components/header/Header"; +import { Toaster } from "../../components/toast/toaster"; +import Footer from "../../components/footer/Footer"; + +const { SITE_NAME } = process.env; +const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL + ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` + : "http://localhost:3000"; + +export const metadata = { + metadataBase: new URL(baseUrl), + title: { + default: SITE_NAME!, + template: `%s | ${SITE_NAME}`, + }, + robots: { + follow: true, + index: true, + }, +}; + +/** + * Used to revalidate until the js-sdk supports passing of fetch options. + * At that point we can be more intelligent about revalidation. + */ +export const revalidate = 300; + +const inter = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-inter", +}); + +export default async function StoreLayout({ + children, +}: { + children: ReactNode; +}) { + const client = getServerSideImplicitClient(); + const initialState = await getStoreInitialState(client); + + return ( + + + {/* headless ui needs this div - https://github.com/tailwindlabs/headlessui/issues/2752#issuecomment-1745272229 */} +
+ +
+ + +
{children}
+
+
+ +
+ + + ); +} diff --git a/examples/algolia/src/app/(store)/not-found.tsx b/examples/algolia/src/app/(store)/not-found.tsx new file mode 100644 index 00000000..d97d88f4 --- /dev/null +++ b/examples/algolia/src/app/(store)/not-found.tsx @@ -0,0 +1,13 @@ +import Link from "next/link"; +export default function NotFound() { + return ( +
+ + 404 - The page could not be found. + + + Back to home + +
+ ); +} diff --git a/examples/simple/src/app/page.tsx b/examples/algolia/src/app/(store)/page.tsx similarity index 84% rename from examples/simple/src/app/page.tsx rename to examples/algolia/src/app/(store)/page.tsx index dfd06746..6f87cd28 100644 --- a/examples/simple/src/app/page.tsx +++ b/examples/algolia/src/app/(store)/page.tsx @@ -1,5 +1,5 @@ -import PromotionBanner from "../components/promotion-banner/PromotionBanner"; -import FeaturedProducts from "../components/featured-products/FeaturedProducts"; +import PromotionBanner from "../../components/promotion-banner/PromotionBanner"; +import FeaturedProducts from "../../components/featured-products/FeaturedProducts"; import { Suspense } from "react"; export default async function Home() { diff --git a/examples/algolia/src/app/products/[productId]/page.tsx b/examples/algolia/src/app/(store)/products/[productId]/page.tsx similarity index 73% rename from examples/algolia/src/app/products/[productId]/page.tsx rename to examples/algolia/src/app/(store)/products/[productId]/page.tsx index f0a54aed..d6272b15 100644 --- a/examples/algolia/src/app/products/[productId]/page.tsx +++ b/examples/algolia/src/app/(store)/products/[productId]/page.tsx @@ -1,9 +1,10 @@ import { Metadata } from "next"; -import { ProductDisplay } from "./product-display"; -import { getServerSideImplicitClient } from "../../../lib/epcc-server-side-implicit-client"; -import { getProductById } from "../../../services/products"; +import { ProductDetailsComponent, ProductProvider } from "./product-display"; +import { getServerSideImplicitClient } from "../../../../lib/epcc-server-side-implicit-client"; +import { getProductById } from "../../../../services/products"; import { notFound } from "next/navigation"; import { parseProductResponse } from "@elasticpath/shopper-common"; +import React from "react"; export const dynamic = "force-dynamic"; @@ -42,7 +43,9 @@ export default async function ProductPage({ params }: Props) { className="px-4 xl:px-0 py-8 mx-auto max-w-[48rem] md:py-20 lg:max-w-[80rem] w-full" key={"page_" + params.productId} > - + + +
); } diff --git a/examples/payments/src/app/products/[productId]/product-display.tsx b/examples/algolia/src/app/(store)/products/[productId]/product-display.tsx similarity index 52% rename from examples/payments/src/app/products/[productId]/product-display.tsx rename to examples/algolia/src/app/(store)/products/[productId]/product-display.tsx index 2664951b..98ab9c15 100644 --- a/examples/payments/src/app/products/[productId]/product-display.tsx +++ b/examples/algolia/src/app/(store)/products/[productId]/product-display.tsx @@ -1,15 +1,15 @@ "use client"; -import React, { ReactElement, useState } from "react"; +import React, { ReactElement, ReactNode, useState } from "react"; import { ShopperProduct } from "@elasticpath/react-shopper-hooks"; -import { VariationProductDetail } from "../../../components/product/variations/VariationProduct"; -import BundleProductDetail from "../../../components/product/bundles/BundleProduct"; -import { ProductContext } from "../../../lib/product-context"; -import SimpleProductDetail from "../../../components/product/SimpleProduct"; +import { VariationProductDetail } from "../../../../components/product/variations/VariationProduct"; +import BundleProductDetail from "../../../../components/product/bundles/BundleProduct"; +import { ProductContext } from "../../../../lib/product-context"; +import SimpleProductDetail from "../../../../components/product/SimpleProduct"; -export function ProductDisplay({ - product, +export function ProductProvider({ + children, }: { - product: ShopperProduct; + children: ReactNode; }): ReactElement { const [isChangingSku, setIsChangingSku] = useState(false); @@ -20,12 +20,14 @@ export function ProductDisplay({ setIsChangingSku, }} > - {resolveProductDetailComponent(product)} + {children} ); } -function resolveProductDetailComponent(product: ShopperProduct): JSX.Element { +export function resolveProductDetailComponent( + product: ShopperProduct, +): JSX.Element { switch (product.kind) { case "base-product": return ; @@ -37,3 +39,11 @@ function resolveProductDetailComponent(product: ShopperProduct): JSX.Element { return ; } } + +export function ProductDetailsComponent({ + product, +}: { + product: ShopperProduct; +}) { + return resolveProductDetailComponent(product); +} diff --git a/examples/algolia/src/app/search/[[...node]]/layout.tsx b/examples/algolia/src/app/(store)/search/[[...node]]/layout.tsx similarity index 79% rename from examples/algolia/src/app/search/[[...node]]/layout.tsx rename to examples/algolia/src/app/(store)/search/[[...node]]/layout.tsx index 9e1fb2bd..937b6e50 100644 --- a/examples/algolia/src/app/search/[[...node]]/layout.tsx +++ b/examples/algolia/src/app/(store)/search/[[...node]]/layout.tsx @@ -1,5 +1,5 @@ import { ReactNode } from "react"; -import Breadcrumb from "../../../components/breadcrumb"; +import Breadcrumb from "../../../../components/breadcrumb"; export default function SearchLayout({ children }: { children: ReactNode }) { return ( diff --git a/examples/algolia/src/app/search/[[...node]]/page.tsx b/examples/algolia/src/app/(store)/search/[[...node]]/page.tsx similarity index 100% rename from examples/algolia/src/app/search/[[...node]]/page.tsx rename to examples/algolia/src/app/(store)/search/[[...node]]/page.tsx diff --git a/examples/algolia/src/app/search/search.tsx b/examples/algolia/src/app/(store)/search/search.tsx similarity index 79% rename from examples/algolia/src/app/search/search.tsx rename to examples/algolia/src/app/(store)/search/search.tsx index c0f077f0..8df2612d 100644 --- a/examples/algolia/src/app/search/search.tsx +++ b/examples/algolia/src/app/(store)/search/search.tsx @@ -1,11 +1,11 @@ "use client"; -import { searchClient } from "../../lib/search-client"; +import { searchClient } from "../../../lib/search-client"; import { InstantSearchNext } from "react-instantsearch-nextjs"; -import { algoliaEnvData } from "../../lib/resolve-algolia-env"; -import { resolveAlgoliaRouting } from "../../lib/algolia-search-routing"; -import SearchResults from "../../components/search/SearchResults"; +import { algoliaEnvData } from "../../../lib/resolve-algolia-env"; +import { resolveAlgoliaRouting } from "../../../lib/algolia-search-routing"; +import SearchResults from "../../../components/search/SearchResults"; import React from "react"; -import { buildBreadcrumbLookup } from "../../lib/build-breadcrumb-lookup"; +import { buildBreadcrumbLookup } from "../../../lib/build-breadcrumb-lookup"; import { useStore } from "@elasticpath/react-shopper-hooks"; import { HierarchicalMenuProps, @@ -21,8 +21,8 @@ import { useSearchBox, useSortBy, } from "react-instantsearch"; -import { sortByItems } from "../../lib/sort-by-items"; -import { hierarchicalAttributes } from "../../lib/hierarchical-attributes"; +import { sortByItems } from "../../../lib/sort-by-items"; +import { hierarchicalAttributes } from "../../../lib/hierarchical-attributes"; export function Search() { const { nav } = useStore(); diff --git a/examples/payments/src/app/shipping/page.tsx b/examples/algolia/src/app/(store)/shipping/page.tsx similarity index 58% rename from examples/payments/src/app/shipping/page.tsx rename to examples/algolia/src/app/(store)/shipping/page.tsx index 7be31b80..d5ee20b4 100644 --- a/examples/payments/src/app/shipping/page.tsx +++ b/examples/algolia/src/app/(store)/shipping/page.tsx @@ -1,4 +1,4 @@ -import Blurb from "../../components/shared/blurb"; +import Blurb from "../../../components/shared/blurb"; export default function Shipping() { return ; diff --git a/examples/payments/src/app/terms/page.tsx b/examples/algolia/src/app/(store)/terms/page.tsx similarity index 60% rename from examples/payments/src/app/terms/page.tsx rename to examples/algolia/src/app/(store)/terms/page.tsx index 8efeb5e7..3b651abc 100644 --- a/examples/payments/src/app/terms/page.tsx +++ b/examples/algolia/src/app/(store)/terms/page.tsx @@ -1,4 +1,4 @@ -import Blurb from "../../components/shared/blurb"; +import Blurb from "../../../components/shared/blurb"; export default function Terms() { return ; diff --git a/examples/algolia/src/app/cart/page.tsx b/examples/algolia/src/app/cart/page.tsx deleted file mode 100644 index 4dc53f4b..00000000 --- a/examples/algolia/src/app/cart/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; -import Cart from "../../components/cart/Cart"; -import CartIcon from "../../components/icons/cart"; -import { useCart } from "@elasticpath/react-shopper-hooks"; -import { resolveShoppingCartProps } from "../../lib/resolve-shopping-cart-props"; -import Link from "next/link"; - -export default function CartPage() { - const { removeCartItem, state } = useCart(); - const shoppingCartProps = resolveShoppingCartProps(state, removeCartItem); - - return ( -
- {shoppingCartProps && ( - <> -

Your Shopping Cart

- - - )} - {(state.kind === "empty-cart-state" || - state.kind === "uninitialised-cart-state" || - state.kind === "loading-cart-state") && ( -
- -

- Empty Cart -

-

Your cart is empty

-
- - Start shopping - -
-
- )} -
- ); -} diff --git a/examples/algolia/src/app/layout.tsx b/examples/algolia/src/app/layout.tsx index b4b06802..d87bb7de 100644 --- a/examples/algolia/src/app/layout.tsx +++ b/examples/algolia/src/app/layout.tsx @@ -1,59 +1,10 @@ -import { Inter } from "next/font/google"; -import { ReactNode, Suspense } from "react"; +import { ReactNode } from "react"; import "../styles/globals.css"; -import Header from "../components/header/Header"; -import { getStoreContext } from "../lib/get-store-context"; -import { getServerSideImplicitClient } from "../lib/epcc-server-side-implicit-client"; -import { Providers } from "./providers"; -import { Toaster } from "../components/toast/toaster"; -import Footer from "../components/footer/Footer"; - -const { SITE_NAME } = process.env; -const baseUrl = process.env.NEXT_PUBLIC_VERCEL_URL - ? `https://${process.env.NEXT_PUBLIC_VERCEL_URL}` - : "http://localhost:3000"; - -export const metadata = { - metadataBase: new URL(baseUrl), - title: { - default: SITE_NAME!, - template: `%s | ${SITE_NAME}`, - }, - robots: { - follow: true, - index: true, - }, -}; - -const inter = Inter({ - subsets: ["latin"], - display: "swap", - variable: "--font-inter", -}); export default async function RootLayout({ children, }: { children: ReactNode; }) { - const client = getServerSideImplicitClient(); - const storeContext = await getStoreContext(client); - - return ( - - - {/* headless ui needs this div - https://github.com/tailwindlabs/headlessui/issues/2752#issuecomment-1745272229 */} -
- -
- - -
{children}
-
-
- -
- - - ); + return <>{children}; } diff --git a/examples/algolia/src/app/providers.tsx b/examples/algolia/src/app/providers.tsx index 5bcdc503..5ccb9cd8 100644 --- a/examples/algolia/src/app/providers.tsx +++ b/examples/algolia/src/app/providers.tsx @@ -1,17 +1,51 @@ "use client"; - -import StoreNextJSProvider from "../lib/providers/store-provider"; import { ReactNode } from "react"; -import { StoreContext } from "@elasticpath/react-shopper-hooks"; +import { + AccountProvider, + StoreProvider, + ElasticPathProvider, + InitialState, +} from "@elasticpath/react-shopper-hooks"; +import { QueryClient } from "@tanstack/react-query"; +import { getEpccImplicitClient } from "../lib/epcc-implicit-client"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../lib/cookie-constants"; +import { getCookie } from "cookies-next"; +import { COOKIE_PREFIX_KEY } from "../lib/resolve-cart-env"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + staleTime: 1000 * 60 * 60 * 24, + retry: 1, + }, + }, +}); export function Providers({ children, - store, + initialState, }: { children: ReactNode; - store: StoreContext; + initialState: InitialState; }) { + const client = getEpccImplicitClient(); + + /** + * The cart cookie is set by nextjs middleware. + */ + const cartCookie = getCookie(`${COOKIE_PREFIX_KEY}_ep_cart`); + return ( - {children} + + + + {children} + + + ); } diff --git a/examples/algolia/src/components/Checkbox.tsx b/examples/algolia/src/components/Checkbox.tsx new file mode 100644 index 00000000..290cf273 --- /dev/null +++ b/examples/algolia/src/components/Checkbox.tsx @@ -0,0 +1,29 @@ +"use client"; + +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { cn } from "../lib/cn"; +import { CheckIcon } from "@heroicons/react/24/solid"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/examples/algolia/src/components/LoadingDots.tsx b/examples/algolia/src/components/LoadingDots.tsx new file mode 100644 index 00000000..5ac56f3d --- /dev/null +++ b/examples/algolia/src/components/LoadingDots.tsx @@ -0,0 +1,13 @@ +import { cn } from "../lib/cn"; + +const dots = "mx-[1px] inline-block h-1 w-1 animate-blink rounded-md"; + +export function LoadingDots({ className }: { className: string }) { + return ( + + + + + + ); +} diff --git a/examples/algolia/src/components/accordion/Accordion.tsx b/examples/algolia/src/components/accordion/Accordion.tsx new file mode 100644 index 00000000..6f4f7aef --- /dev/null +++ b/examples/algolia/src/components/accordion/Accordion.tsx @@ -0,0 +1,51 @@ +"use client"; + +import * as React from "react"; +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { cn } from "../../lib/cn"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + {children} + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); + +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/examples/algolia/src/components/alert/Alert.tsx b/examples/algolia/src/components/alert/Alert.tsx new file mode 100644 index 00000000..eb8d9cd4 --- /dev/null +++ b/examples/algolia/src/components/alert/Alert.tsx @@ -0,0 +1,59 @@ +import { cva, type VariantProps } from "class-variance-authority"; + +import { forwardRef, HTMLAttributes } from "react"; +import { cn } from "../../lib/cn"; + +const alertVariants = cva( + "relative w-full rounded-lg border border-black/60 p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-black", + { + variants: { + variant: { + default: "bg-white text-black", + destructive: + "border-red-600/50 text-red-600 dark:border-red-600 [&>svg]:text-red-600", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +const Alert = forwardRef< + HTMLDivElement, + HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)); +Alert.displayName = "Alert"; + +const AlertTitle = forwardRef< + HTMLParagraphElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertTitle.displayName = "AlertTitle"; + +const AlertDescription = forwardRef< + HTMLParagraphElement, + HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)); +AlertDescription.displayName = "AlertDescription"; + +export { Alert, AlertTitle, AlertDescription }; diff --git a/examples/algolia/src/components/button/Button.tsx b/examples/algolia/src/components/button/Button.tsx new file mode 100644 index 00000000..6d277a4e --- /dev/null +++ b/examples/algolia/src/components/button/Button.tsx @@ -0,0 +1,68 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "../../lib/cn"; +import { ButtonHTMLAttributes, forwardRef } from "react"; +import { Slot } from "@radix-ui/react-slot"; + +const buttonVariants = cva( + "inline-flex items-center justify-center hover:opacity-90 whitespace-nowrap rounded-full text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + primary: "bg-black text-white", + secondary: "bg-transparent ring-2 ring-inset ring-black text-black", + ghost: "bg-brand-gray/10 text-black/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "px-8 py-3 text-lg", + medium: "px-6 py-2 text-base", + small: "px-3.5 py-1.5 text-sm", + icon: "w-10", + }, + reversed: { + true: "", + false: "", + }, + }, + compoundVariants: [ + { + variant: "primary", + reversed: true, + className: "bg-white text-black", + }, + { + variant: "secondary", + reversed: true, + className: "ring-white text-white", + }, + ], + defaultVariants: { + variant: "primary", + size: "default", + reversed: false, + }, + }, +); + +export interface ButtonProps + extends ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Button = forwardRef( + ({ className, variant, asChild = false, size, reversed, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + + return ( + + ); + }, +); +Button.displayName = "Button"; + +export { Button, buttonVariants }; diff --git a/examples/algolia/src/components/button/FormStatusButton.tsx b/examples/algolia/src/components/button/FormStatusButton.tsx new file mode 100644 index 00000000..2b46a37e --- /dev/null +++ b/examples/algolia/src/components/button/FormStatusButton.tsx @@ -0,0 +1,41 @@ +"use client"; +import { cn } from "../../lib/cn"; +import { forwardRef, ReactNode } from "react"; +import { Button, ButtonProps } from "./Button"; +import LoaderIcon from "./LoaderIcon"; +import { useFormStatus } from "react-dom"; +import * as React from "react"; + +export interface FormStatusButtonProps extends ButtonProps { + status?: "loading" | "success" | "error" | "idle"; + icon?: ReactNode; +} + +const FormStatusButton = forwardRef( + ({ children, icon, status = "idle", className, ...props }, ref) => { + const { pending } = useFormStatus(); + return ( + + ); + }, +); +FormStatusButton.displayName = "FormStatusButton"; + +export { FormStatusButton }; diff --git a/examples/algolia/src/components/button/LoaderIcon.tsx b/examples/algolia/src/components/button/LoaderIcon.tsx new file mode 100644 index 00000000..92aa3952 --- /dev/null +++ b/examples/algolia/src/components/button/LoaderIcon.tsx @@ -0,0 +1,17 @@ +import { forwardRef, Ref, SVGProps } from "react"; + +const LoaderIcon = ( + props: SVGProps, + ref: Ref, +) => ( + + + +); +const ForwardRef = forwardRef(LoaderIcon); +export default ForwardRef; diff --git a/examples/algolia/src/components/button/StatusButton.tsx b/examples/algolia/src/components/button/StatusButton.tsx new file mode 100644 index 00000000..12ee784a --- /dev/null +++ b/examples/algolia/src/components/button/StatusButton.tsx @@ -0,0 +1,47 @@ +import { cn } from "../../lib/cn"; +import { forwardRef } from "react"; +import { Button, ButtonProps } from "./Button"; +import { CheckIcon } from "@heroicons/react/24/outline"; +import { XMarkIcon } from "@heroicons/react/24/solid"; +import LoaderIcon from "./LoaderIcon"; + +export interface StatusButtonProps extends ButtonProps { + status?: "loading" | "success" | "error" | "idle"; +} + +const StatusButton = forwardRef( + ({ children, status = "idle", className, ...props }, ref) => { + const Icon = + status === "loading" + ? LoaderIcon + : status === "success" + ? CheckIcon + : status === "error" + ? XMarkIcon + : null; + + return ( + + ); + }, +); +StatusButton.displayName = "StatusButton"; + +export { StatusButton }; diff --git a/examples/algolia/src/components/button/TextButton.tsx b/examples/algolia/src/components/button/TextButton.tsx new file mode 100644 index 00000000..e48149af --- /dev/null +++ b/examples/algolia/src/components/button/TextButton.tsx @@ -0,0 +1,41 @@ +import { cva, VariantProps } from "class-variance-authority"; +import { ButtonHTMLAttributes, forwardRef } from "react"; +import { Slot } from "@radix-ui/react-slot"; +import { cn } from "../../lib/cn"; + +const textButtonVariants = cva( + "font-medium text-black flex items-center gap-2 shrink-0", + { + variants: { + size: { + default: "text-lg", + small: "text-base", + }, + }, + defaultVariants: { + size: "default", + }, + }, +); + +export interface TextButtonProps + extends ButtonHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const TextButton = forwardRef( + ({ className, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +TextButton.displayName = "TextButton"; + +export { TextButton, textButtonVariants }; diff --git a/examples/algolia/src/components/cart/Cart.tsx b/examples/algolia/src/components/cart/Cart.tsx deleted file mode 100644 index ebe3e097..00000000 --- a/examples/algolia/src/components/cart/Cart.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { - GroupedCartItems, - RefinedCartItem, -} from "@elasticpath/react-shopper-hooks"; -import { CartItemList } from "./CartItemList"; -import { CartOrderSummary } from "./CartOrderSummary"; -import { ReadonlyNonEmptyArray } from "../../lib/types/read-only-non-empty-array"; - -export interface ICart { - id: string; - items: ReadonlyNonEmptyArray; - groupedItems: GroupedCartItems; - totalPrice: string; - subtotal: string; - removeCartItem: (itemId: string) => Promise; -} - -export default function Cart({ - id, - items, - groupedItems, - totalPrice, - subtotal, - removeCartItem, -}: ICart): JSX.Element { - return ( -
- - -
- ); -} diff --git a/examples/algolia/src/components/cart/CartDiscounts.tsx b/examples/algolia/src/components/cart/CartDiscounts.tsx new file mode 100644 index 00000000..f9ec771d --- /dev/null +++ b/examples/algolia/src/components/cart/CartDiscounts.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { forwardRef, Fragment, HTMLAttributes } from "react"; +import { Separator } from "../separator/Separator"; +import { PromotionCartItem, useCart } from "@elasticpath/react-shopper-hooks"; +import { LoadingDots } from "../LoadingDots"; +import { XMarkIcon } from "@heroicons/react/24/solid"; +import * as React from "react"; +import { cn } from "../../lib/cn"; + +export function CartDiscounts({ + promotions, +}: { + promotions: PromotionCartItem[]; +}) { + const { useScopedRemoveCartItem } = useCart(); + const { mutate, isPending } = useScopedRemoveCartItem(); + + return ( + promotions && + promotions.length > 0 && + promotions.map((promotion) => { + return ( + + + + {promotion.name} + + + + ); + }) + ); +} + +export function CartDiscountsReadOnly({ + promotions, +}: { + promotions: PromotionCartItem[]; +}) { + return ( + promotions && + promotions.length > 0 && + promotions.map((promotion) => { + return ( + + + {promotion.name} + + + + ); + }) + ); +} + +const CartDiscountItem = forwardRef< + HTMLDivElement, + HTMLAttributes +>(({ className, children, ...props }, ref) => { + return ( +
+
{children}
+
+ ); +}); +CartDiscountItem.displayName = "CartDiscountItem"; + +const CartDiscountName = forwardRef< + HTMLSpanElement, + HTMLAttributes +>(({ className, children, ...props }, ref) => { + return ( + + {children} + + ); +}); +CartDiscountName.displayName = "CartDiscountName"; diff --git a/examples/algolia/src/components/cart/CartItemList.tsx b/examples/algolia/src/components/cart/CartItemList.tsx deleted file mode 100644 index 71c8c0dc..00000000 --- a/examples/algolia/src/components/cart/CartItemList.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { RefinedCartItem } from "@elasticpath/react-shopper-hooks"; -import QuantityHandler from "../quantity-handler/QuantityHandler"; -import { NonEmptyArray } from "../../lib/types/non-empty-array"; -import { ReadonlyNonEmptyArray } from "../../lib/types/read-only-non-empty-array"; -import Image from "next/image"; -import { XMarkIcon } from "@heroicons/react/24/solid"; - -export function CartItemList({ - items, - handleRemoveItem, -}: { - items: - | RefinedCartItem[] - | NonEmptyArray - | ReadonlyNonEmptyArray; - handleRemoveItem: (itemId: string) => Promise; -}): JSX.Element { - return ( -
- {items.map((item) => ( -
-
- {item.image?.href && ( - {item.name} - )} -
- -
- - {item.name} - - - {item.meta.display_price.without_tax.unit.formatted} - -
-
- - { - handleRemoveItem(item.id); - }} - /> -
-
- ))} -
- ); -} diff --git a/examples/algolia/src/components/cart/CartOrderSummary.tsx b/examples/algolia/src/components/cart/CartOrderSummary.tsx deleted file mode 100644 index 891304ef..00000000 --- a/examples/algolia/src/components/cart/CartOrderSummary.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { PromotionCartItem } from "@elasticpath/react-shopper-hooks"; -import { Promotion } from "./Promotion"; -import Link from "next/link"; - -export function CartOrderSummary({ - cartId, - totalPrice, - subtotal, - promotionItems, - handleRemoveItem, -}: { - cartId: string; - totalPrice: string; - subtotal: string; - promotionItems: PromotionCartItem[]; - handleRemoveItem: (itemId: string) => Promise; -}): JSX.Element { - return ( -
- Order Summary - - - - - - - {/* Couldn't find any promotional items */} - {promotionItems?.map((item) => { - return ( - - - - - ); - })} - - - - - -
Subtotal{subtotal}
-
- Discount - {item.sku} -
-
- {promotionItems && promotionItems.length > 0 ? ( -
- - { - promotionItems[0].meta.display_price.without_tax.unit - .formatted - } - - -
- ) : ( - "$0.00" - )} -
Order Total{totalPrice}
- -
- -
-
- - - - - - -
-
- ); -} diff --git a/examples/algolia/src/components/cart/CartSheet.tsx b/examples/algolia/src/components/cart/CartSheet.tsx new file mode 100644 index 00000000..6fc6633d --- /dev/null +++ b/examples/algolia/src/components/cart/CartSheet.tsx @@ -0,0 +1,166 @@ +"use client"; +import { + Sheet, + SheetClose, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "../sheet/Sheet"; +import { Button } from "../button/Button"; +import { LockClosedIcon, XMarkIcon } from "@heroicons/react/24/solid"; +import { CartItem } from "../../app/(store)/cart/CartItem"; +import { useCart } from "@elasticpath/react-shopper-hooks"; +import { Separator } from "../separator/Separator"; +import { ShoppingBagIcon } from "@heroicons/react/24/outline"; +import { Fragment } from "react"; +import { AddPromotion } from "../checkout-sidebar/AddPromotion"; +import Link from "next/link"; +import { LoadingDots } from "../LoadingDots"; + +export function Cart() { + const { state, useScopedRemoveCartItem } = useCart(); + + const { items, __extended } = state ?? {}; + + const { mutate, isPending } = useScopedRemoveCartItem(); + + const discountedValues = ( + state?.meta?.display_price as + | { discount: { amount: number; formatted: string } } + | undefined + )?.discount; + + return ( + + + + + + +
+ + Your Bag + + + + Close + +
+ {items && items.length > 0 ? ( + <> + {/* Items */} +
+
    + {items.map((item) => { + return ( + +
  • + +
  • + +
    + ); + })} +
+
+ {/* Bottom */} + +
+ +
+ {__extended && + __extended.groupedItems.promotion.length > 0 && + __extended.groupedItems.promotion.map((promotion) => { + return ( + + +
+
+ + {promotion.name} +
+
+
+ ); + })} + + {/* Totals */} +
+
+ Sub Total + + {state?.meta?.display_price?.without_tax?.formatted} + +
+ {discountedValues && discountedValues.amount !== 0 && ( +
+ Discount + + {discountedValues.formatted} + +
+ )} +
+ + + + + + + +
+ + ) : ( +
+ +

Your bag is empty.

+
+ )} +
+
+ ); +} diff --git a/examples/algolia/src/components/cart/Promotion.tsx b/examples/algolia/src/components/cart/Promotion.tsx deleted file mode 100644 index 19906809..00000000 --- a/examples/algolia/src/components/cart/Promotion.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { useFormik } from "formik"; -import { useCart } from "@elasticpath/react-shopper-hooks"; - -interface FormValues { - promoCode: string; -} - -export const Promotion = (): JSX.Element => { - const { addPromotionToCart, state } = useCart(); - - const initialValues: FormValues = { - promoCode: "", - }; - - const { handleSubmit, handleChange, values } = useFormik({ - initialValues, - onSubmit: async (values) => { - await addPromotionToCart(values.promoCode); - // TODO handle invalid promo code setErrors(error.errors[0].detail); - }, - }); - - const shouldDisableInput = - state.kind !== "present-cart-state" || - state.groupedItems.promotion.length > 0; - - return ( -
-
-
-
- -
- - -
-
-
-
-
- ); -}; diff --git a/examples/algolia/src/components/checkout-item/CheckoutItem.tsx b/examples/algolia/src/components/checkout-item/CheckoutItem.tsx new file mode 100644 index 00000000..b6577937 --- /dev/null +++ b/examples/algolia/src/components/checkout-item/CheckoutItem.tsx @@ -0,0 +1,32 @@ +"use client"; +import { ProductThumbnail } from "../../app/(store)/account/orders/[orderId]/ProductThumbnail"; +import Link from "next/link"; +import { CartItem } from "@moltin/sdk"; + +export function CheckoutItem({ item }: { item: CartItem }) { + return ( +
+
+ +
+
+ + {item.name} + + Quantity: {item.quantity} +
+
+ + {item.meta.display_price.with_tax.value.formatted} + + {item.meta.display_price.without_discount?.value.amount && + item.meta.display_price.without_discount?.value.amount !== + item.meta.display_price.with_tax.value.amount && ( + + {item.meta.display_price.without_discount?.value.formatted} + + )} +
+
+ ); +} diff --git a/examples/algolia/src/components/checkout-sidebar/AddPromotion.tsx b/examples/algolia/src/components/checkout-sidebar/AddPromotion.tsx new file mode 100644 index 00000000..566f83d3 --- /dev/null +++ b/examples/algolia/src/components/checkout-sidebar/AddPromotion.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useState } from "react"; +import { TextButton } from "../button/TextButton"; +import { PlusIcon } from "@heroicons/react/24/outline"; +import { Input } from "../input/Input"; +import { Button } from "../button/Button"; +import { applyDiscount } from "./actions"; +import { useQueryClient } from "@tanstack/react-query"; +import { cartQueryKeys, useCart } from "@elasticpath/react-shopper-hooks"; +import { useFormStatus } from "react-dom"; +import { LoadingDots } from "../LoadingDots"; + +export function AddPromotion() { + const [showInput, setShowInput] = useState(false); + const queryClient = useQueryClient(); + const { cartId } = useCart(); + const [error, setError] = useState(undefined); + + async function clientAction(formData: FormData) { + setError(undefined); + + const result = await applyDiscount(formData); + + setError(result.error); + + cartId && + (await queryClient.invalidateQueries({ + queryKey: cartQueryKeys.detail(cartId), + })); + + !result.error && setShowInput(false); + } + + return showInput ? ( +
+
+ + + + {error &&

{error}

} +
+ ) : ( + setShowInput(true)}> + Add discount code + + ); +} + +function ApplyButton() { + const { pending } = useFormStatus(); + + return ( + + ); +} diff --git a/examples/algolia/src/components/checkout-sidebar/ItemSidebar.tsx b/examples/algolia/src/components/checkout-sidebar/ItemSidebar.tsx new file mode 100644 index 00000000..bc259f4e --- /dev/null +++ b/examples/algolia/src/components/checkout-sidebar/ItemSidebar.tsx @@ -0,0 +1,166 @@ +"use client"; +import { Separator } from "../separator/Separator"; +import { AddPromotion } from "./AddPromotion"; +import { CheckoutItem } from "../checkout-item/CheckoutItem"; +import { CartState } from "@elasticpath/react-shopper-hooks"; +import { + Accordion, + AccordionContent, + AccordionTrigger, + AccordionItem, +} from "../accordion/Accordion"; +import { ShoppingBagIcon } from "@heroicons/react/24/outline"; +import { ChevronDownIcon } from "@heroicons/react/24/solid"; +import * as React from "react"; +import { Currency } from "@moltin/sdk"; +import { formatCurrency } from "../../lib/format-currency"; + +export function ItemSidebarItems({ items }: { items: CartState["items"] }) { + return ( + <> + {items && items.length > 0 && ( + <> + {items?.map((item) => )} + + + )} + + ); +} + +export function ItemSidebarPromotions() { + return ( +
+ +
+ ); +} + +export function ItemSidebarSumTotal({ meta }: { meta: CartState["meta"] }) { + return ( +
+ Total +
+ {meta?.display_price?.with_tax?.currency} + + {meta?.display_price?.with_tax?.formatted} + +
+
+ ); +} + +export function ItemSidebarTotals({ children }: { children: React.ReactNode }) { + return ( +
{children}
+ ); +} + +export function ItemSidebarTotalsSubTotal({ + meta, +}: { + meta: CartState["meta"]; +}) { + return ( + + ); +} + +export function ItemSidebarTotalsDiscount({ + meta, +}: { + meta: CartState["meta"]; +}) { + const discountedValues = ( + meta?.display_price as + | { discount: { amount: number; formatted: string } } + | undefined + )?.discount; + + return ( + discountedValues && + discountedValues.amount !== 0 && ( +
+ Discount + + {discountedValues?.formatted ?? ""} + +
+ ) + ); +} + +export function ItemSidebarTotalsTax({ meta }: { meta: CartState["meta"] }) { + return ( + + ); +} + +export function ItemSidebarTotalsItem({ + label, + description, +}: { + label: string; + description: string; +}) { + return ( +
+ {label} + {description} +
+ ); +} + +export function ItemSidebarHideable({ + children, + meta, +}: { + meta: CartState["meta"]; + children: React.ReactNode; +}) { + return ( + <> + + + + + Hide order summary + + + {meta?.display_price?.with_tax?.formatted} + + + {children} + + +
{children}
+ + ); +} + +export function resolveTotalInclShipping( + shippingAmount: number, + totalAmount: number, + storeCurrency: Currency, +): string | undefined { + return formatCurrency(shippingAmount + totalAmount, storeCurrency); +} diff --git a/examples/algolia/src/components/checkout-sidebar/actions.ts b/examples/algolia/src/components/checkout-sidebar/actions.ts new file mode 100644 index 00000000..e2d283b8 --- /dev/null +++ b/examples/algolia/src/components/checkout-sidebar/actions.ts @@ -0,0 +1,43 @@ +"use server"; +import { getServerSideImplicitClient } from "../../lib/epcc-server-side-implicit-client"; +import { cookies } from "next/headers"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { COOKIE_PREFIX_KEY } from "../../lib/resolve-cart-env"; +import { getErrorMessage } from "../../lib/get-error-message"; + +const applyDiscountSchema = z.object({ + code: z.string(), +}); + +export async function applyDiscount(formData: FormData) { + const client = getServerSideImplicitClient(); + + const cartCookie = cookies().get(`${COOKIE_PREFIX_KEY}_ep_cart`)?.value; + + if (!cartCookie) { + throw new Error("Cart cookie not found"); + } + + const rawEntries = Object.fromEntries(formData.entries()); + + const validatedFormData = applyDiscountSchema.safeParse(rawEntries); + + if (!validatedFormData.success) { + return { + error: "Invalid code", + }; + } + + try { + await client.Cart(cartCookie).AddPromotion(validatedFormData.data.code); + revalidatePath("/cart"); + } catch (error) { + console.error(error); + return { + error: getErrorMessage(error), + }; + } + + return { success: true }; +} diff --git a/examples/algolia/src/components/checkout/form-schema/checkout-form-schema.ts b/examples/algolia/src/components/checkout/form-schema/checkout-form-schema.ts new file mode 100644 index 00000000..5c987167 --- /dev/null +++ b/examples/algolia/src/components/checkout/form-schema/checkout-form-schema.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +/** + * Validating optional text input field https://github.com/colinhacks/zod/issues/310 + */ +const emptyStringToUndefined = z.literal("").transform(() => undefined); + +const guestInformationSchema = z.object({ + email: z.string({ required_error: "Required" }).email("Invalid email"), +}); + +const accountMemberInformationSchema = z.object({ + email: z.string({ required_error: "Required" }).email("Invalid email"), + name: z.string({ required_error: "Required" }), +}); + +const billingAddressSchema = z.object({ + first_name: z + .string({ required_error: "You need to provided a first name." }) + .min(2), + last_name: z + .string({ required_error: "You need to provided a last name." }) + .min(2), + company_name: z.string().min(1).optional().or(emptyStringToUndefined), + line_1: z + .string({ required_error: "You need to provided an address." }) + .min(1), + line_2: z.string().min(1).optional().or(emptyStringToUndefined), + city: z.string().min(1).optional().or(emptyStringToUndefined), + county: z.string().min(1).optional().or(emptyStringToUndefined), + region: z + .string({ required_error: "You need to provided a region." }) + .optional() + .or(emptyStringToUndefined), + postcode: z + .string({ required_error: "You need to provided a postcode." }) + .min(1), + country: z + .string({ required_error: "You need to provided a country." }) + .min(1), +}); + +export const shippingAddressSchema = z + .object({ + phone_number: z + .string() + .regex( + /^(\+?\d{0,4})?\s?-?\s?(\(?\d{3}\)?)\s?-?\s?(\(?\d{3}\)?)\s?-?\s?(\(?\d{4}\)?)?$/, + "Phone number is not valid", + ) + .optional() + .or(emptyStringToUndefined), + instructions: z.string().min(1).optional().or(emptyStringToUndefined), + }) + .merge(billingAddressSchema); + +export const anonymousCheckoutFormSchema = z.object({ + guest: guestInformationSchema, + shippingAddress: shippingAddressSchema, + sameAsShipping: z.boolean().default(true), + billingAddress: billingAddressSchema.optional().or(emptyStringToUndefined), + shippingMethod: z + .union([z.literal("__shipping_standard"), z.literal("__shipping_express")]) + .default("__shipping_standard"), +}); + +export type AnonymousCheckoutForm = z.TypeOf< + typeof anonymousCheckoutFormSchema +>; + +export const accountMemberCheckoutFormSchema = z.object({ + account: accountMemberInformationSchema, + shippingAddress: shippingAddressSchema, + sameAsShipping: z.boolean().default(true), + billingAddress: billingAddressSchema.optional().or(emptyStringToUndefined), + shippingMethod: z + .union([z.literal("__shipping_standard"), z.literal("__shipping_express")]) + .default("__shipping_standard"), +}); + +export type AccountMemberCheckoutForm = z.TypeOf< + typeof accountMemberCheckoutFormSchema +>; + +export const checkoutFormSchema = z.union([ + anonymousCheckoutFormSchema, + accountMemberCheckoutFormSchema, +]); + +export type CheckoutForm = z.TypeOf; diff --git a/examples/algolia/src/components/form/Form.tsx b/examples/algolia/src/components/form/Form.tsx new file mode 100644 index 00000000..4f760fa5 --- /dev/null +++ b/examples/algolia/src/components/form/Form.tsx @@ -0,0 +1,183 @@ +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form"; +import { cn } from "../../lib/cn"; +import { Label } from "../label/Label"; +import { + ComponentPropsWithoutRef, + createContext, + ElementRef, + forwardRef, + HTMLAttributes, + useContext, + useId, +} from "react"; + +const Form = FormProvider; + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName; +}; + +const FormFieldContext = createContext( + {} as FormFieldContextValue, +); + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ); +}; + +const useFormField = () => { + const fieldContext = useContext(FormFieldContext); + const itemContext = useContext(FormItemContext); + const { getFieldState, formState } = useFormContext(); + + const fieldState = getFieldState(fieldContext.name, formState); + + if (!fieldContext) { + throw new Error("useFormField should be used within "); + } + + const { id } = itemContext; + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + }; +}; + +type FormItemContextValue = { + id: string; +}; + +const FormItemContext = createContext( + {} as FormItemContextValue, +); + +const FormItem = forwardRef>( + ({ className, ...props }, ref) => { + const id = useId(); + + return ( + +
+ + ); + }, +); +FormItem.displayName = "FormItem"; + +const FormLabel = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField(); + + return ( +
-
- - +
+ } /> +
diff --git a/examples/algolia/src/components/header/account/AccountMenu.tsx b/examples/algolia/src/components/header/account/AccountMenu.tsx new file mode 100644 index 00000000..5295c633 --- /dev/null +++ b/examples/algolia/src/components/header/account/AccountMenu.tsx @@ -0,0 +1,194 @@ +"use client"; +import { Popover } from "@headlessui/react"; +import { ReactNode } from "react"; +import { + ArrowLeftOnRectangleIcon, + ArrowRightOnRectangleIcon, + ClipboardDocumentListIcon, + MapPinIcon, + UserCircleIcon, + UserIcon, + UserPlusIcon, +} from "@heroicons/react/24/outline"; +import Link from "next/link"; +import { logout } from "../../../app/(auth)/actions"; +import { useAuthedAccountMember } from "@elasticpath/react-shopper-hooks"; +import { usePathname } from "next/navigation"; +import clsx from "clsx"; +import { useFloating } from "@floating-ui/react"; + +export function AccountMenu({ + accountSwitcher, +}: { + accountSwitcher: ReactNode; +}) { + const { data, accountMemberTokens } = useAuthedAccountMember(); + + const pathname = usePathname(); + + const isAccountAuthed = !!data; + + const { refs, floatingStyles } = useFloating({ + placement: "bottom-end", + }); + + return ( + + {({ close }) => { + async function logoutAction() { + await logout(); + close(); + } + + return ( + <> + + + +
+
+ {!isAccountAuthed && ( + <> +
+ + +
+
+ + +
+ + )} + {isAccountAuthed && ( + <> +
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+ + )} +
+ {accountMemberTokens && + Object.keys(accountMemberTokens).length > 1 && ( +
+ <> + + Use store as... + + {accountSwitcher} + +
+ )} +
+
+ + ); + }} +
+ ); +} diff --git a/examples/algolia/src/components/header/account/AccountSwitcher.tsx b/examples/algolia/src/components/header/account/AccountSwitcher.tsx new file mode 100644 index 00000000..c0ad8829 --- /dev/null +++ b/examples/algolia/src/components/header/account/AccountSwitcher.tsx @@ -0,0 +1,52 @@ +"use server"; + +import { selectedAccount } from "../../../app/(auth)/actions"; +import { CheckCircleIcon, UserCircleIcon } from "@heroicons/react/24/outline"; +import { cookies } from "next/headers"; +import { retrieveAccountMemberCredentials } from "../../../lib/retrieve-account-member-credentials"; +import { ACCOUNT_MEMBER_TOKEN_COOKIE_NAME } from "../../../lib/cookie-constants"; +import { SwitchButton } from "./switch-button"; + +export async function AccountSwitcher() { + const cookieStore = cookies(); + const accountMemberCookie = retrieveAccountMemberCredentials( + cookieStore, + ACCOUNT_MEMBER_TOKEN_COOKIE_NAME, + ); + + if (!accountMemberCookie) { + return null; + } + + const accountMemberTokens = accountMemberCookie.accounts; + const selectedAccountId = accountMemberCookie.selected; + + return Object.keys(accountMemberTokens).map((tokenKey) => { + const value = accountMemberTokens[tokenKey]; + const Icon = + selectedAccountId === value.account_id ? CheckCircleIcon : UserCircleIcon; + return ( +
+ + +
+ ); + }); +} diff --git a/examples/algolia/src/components/header/account/switch-button.tsx b/examples/algolia/src/components/header/account/switch-button.tsx new file mode 100644 index 00000000..4ee4a22f --- /dev/null +++ b/examples/algolia/src/components/header/account/switch-button.tsx @@ -0,0 +1,57 @@ +"use client"; +import { useFormStatus } from "react-dom"; +import clsx from "clsx"; +import { ButtonHTMLAttributes, forwardRef, ReactNode } from "react"; + +export interface SwitchButtonProps + extends ButtonHTMLAttributes { + icon?: ReactNode; +} + +export const SwitchButton = forwardRef( + ({ className, icon, children, ...props }, ref) => { + const { pending } = useFormStatus(); + + return ( + + ); + }, +); + +SwitchButton.displayName = "SwitchButton"; diff --git a/examples/algolia/src/components/header/cart/CartMenu.tsx b/examples/algolia/src/components/header/cart/CartMenu.tsx deleted file mode 100644 index 84234349..00000000 --- a/examples/algolia/src/components/header/cart/CartMenu.tsx +++ /dev/null @@ -1,115 +0,0 @@ -"use client"; -import Link from "next/link"; -import ModalCartItems from "./ModalCartItem"; -import { - CartState, - getPresentCartState, - RefinedCartItem, - useCart, -} from "@elasticpath/react-shopper-hooks"; -import { Popover, Transition } from "@headlessui/react"; -import { Fragment } from "react"; -import { ReadonlyNonEmptyArray } from "@elasticpath/react-shopper-hooks"; - -export default function CartMenu(): JSX.Element { - const { state } = useCart(); - - const stateItems = resolveStateCartItems(state); - - function resolveStateCartItems( - state: CartState, - ): ReadonlyNonEmptyArray | undefined { - const presentCartState = getPresentCartState(state); - return presentCartState && presentCartState.items; - } - - return ( -
- {/* Headless */} - - {({ close }) => ( - <> - - - {stateItems?.length} - - - - - - - - -
-
- -
-
-
- -
-
-
-
- - )} -
-
- ); -} - -function CartPopoverFooter({ - state, - onClose, -}: { - state: CartState; - onClose: () => void; -}): JSX.Element { - const checkoutHref = - state.kind === "present-cart-state" ? `/checkout/${state.id}` : "#"; - const hasCartItems = state.kind === "present-cart-state"; - return ( -
- - - - - - -
- ); -} diff --git a/examples/algolia/src/components/header/cart/ModalCartItem.tsx b/examples/algolia/src/components/header/cart/ModalCartItem.tsx deleted file mode 100644 index 6cd27b08..00000000 --- a/examples/algolia/src/components/header/cart/ModalCartItem.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { useState } from "react"; -import { getPresentCartState, useCart } from "@elasticpath/react-shopper-hooks"; -import { - CartState, - CustomCartItem, - RefinedCartItem, - RegularCartItem, -} from "@elasticpath/react-shopper-hooks"; -import { XMarkIcon } from "@heroicons/react/20/solid"; -import Image from "next/image"; -import Link from "next/link"; -import { ReadonlyNonEmptyArray } from "../../../lib/types/read-only-non-empty-array"; - -function resolveStateCartItems( - state: CartState, -): ReadonlyNonEmptyArray | undefined { - const presentCartState = getPresentCartState(state); - return presentCartState && presentCartState.items; -} - -function ModalCartItem({ - item, - handleRemove, - onClose, -}: { - item: CustomCartItem | RegularCartItem; - handleRemove: (itemId: string) => void; - onClose: () => void; -}): JSX.Element { - const [removing, setRemoving] = useState(false); - - return ( -
-
- {item.image?.href && ( -
- onClose()} - > - {" "} - {item.name} - -
- )} -
- onClose()} - className="line-clamp-2 text-sm font-semibold hover:underline" - > - {item.name} - - - {item.meta.display_price.without_tax.value.formatted} - - Qty {item.quantity} -
- -
-
- ); -} - -export default function ModalCartItems({ - onClose, -}: { - onClose: () => void; -}): JSX.Element { - const { state, removeCartItem } = useCart(); - - const stateItems = resolveStateCartItems(state); - - if (stateItems) { - return ( -
- {stateItems.map((item) => ( -
- -
- ))} -
- ); - } - - if ( - state.kind === "uninitialised-cart-state" || - state.kind === "loading-cart-state" - ) { - return ( -
- {/* Turn this spinner into a component with size props */} - -
- ); - } - - return ( -
- You have no items in your cart! - - - -
- ); -} diff --git a/examples/algolia/src/components/header/navigation/MobileAccountSwitcher.tsx b/examples/algolia/src/components/header/navigation/MobileAccountSwitcher.tsx new file mode 100644 index 00000000..6857f184 --- /dev/null +++ b/examples/algolia/src/components/header/navigation/MobileAccountSwitcher.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { selectedAccount } from "../../../app/(auth)/actions"; +import { CheckCircleIcon, UserCircleIcon } from "@heroicons/react/24/outline"; +import { + accountMemberQueryKeys, + useAuthedAccountMember, +} from "@elasticpath/react-shopper-hooks"; +import { SwitchButton } from "../account/switch-button"; +import { useQueryClient } from "@tanstack/react-query"; +import { Separator } from "../../separator/Separator"; + +export function MobileAccountSwitcher() { + const { data, accountMemberTokens, selectedAccountToken } = + useAuthedAccountMember(); + + const client = useQueryClient(); + + if (!data || !accountMemberTokens) { + return null; + } + + const selectedAccountId = selectedAccountToken?.account_id; + + async function selectedAccountAction(formData: FormData) { + await selectedAccount(formData); + await client.invalidateQueries({ + queryKey: accountMemberQueryKeys.details(), + }); + } + + return Object.keys(accountMemberTokens).length > 1 ? ( +
+ + + Use store as... + + {Object.keys(accountMemberTokens).map((tokenKey) => { + const value = accountMemberTokens[tokenKey]; + const Icon = + selectedAccountId === value.account_id + ? CheckCircleIcon + : UserCircleIcon; + return ( +
+ + +
+ ); + })} +
+ ) : null; +} diff --git a/examples/algolia/src/components/header/navigation/MobileNavBar.tsx b/examples/algolia/src/components/header/navigation/MobileNavBar.tsx index 93156749..06f832d4 100644 --- a/examples/algolia/src/components/header/navigation/MobileNavBar.tsx +++ b/examples/algolia/src/components/header/navigation/MobileNavBar.tsx @@ -1,10 +1,10 @@ "use server"; import Link from "next/link"; -import CartMenu from "../cart/CartMenu"; import EpIcon from "../../icons/ep-icon"; import { MobileNavBarButton } from "./MobileNavBarButton"; import { getServerSideImplicitClient } from "../../../lib/epcc-server-side-implicit-client"; import { buildSiteNavigation } from "../../../lib/build-site-navigation"; +import { Cart } from "../../cart/CartSheet"; export default async function MobileNavBar() { const client = getServerSideImplicitClient(); @@ -22,7 +22,7 @@ export default async function MobileNavBar() {
- +
diff --git a/examples/algolia/src/components/header/navigation/MobileNavBarButton.tsx b/examples/algolia/src/components/header/navigation/MobileNavBarButton.tsx index 7e9392c6..088e3206 100644 --- a/examples/algolia/src/components/header/navigation/MobileNavBarButton.tsx +++ b/examples/algolia/src/components/header/navigation/MobileNavBarButton.tsx @@ -2,32 +2,63 @@ import { useState } from "react"; import NavMenu from "./NavMenu"; import { NavigationNode } from "@elasticpath/react-shopper-hooks"; +import { + Sheet, + SheetClose, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "../../sheet/Sheet"; +import { XMarkIcon } from "@heroicons/react/24/solid"; +import { AccountMobileMenu } from "../AccountMobileMenu"; +import { Separator } from "../../separator/Separator"; +import { MobileAccountSwitcher } from "./MobileAccountSwitcher"; export function MobileNavBarButton({ nav }: { nav: NavigationNode[] }) { - const [showMenu, setShowMenu] = useState(false); + const [open, setOpen] = useState(false); return ( - <> - - - + + + + + + + +
+ + Menu + + + + Close + +
+ + + +
+ +
+
+ ); } diff --git a/examples/algolia/src/components/header/navigation/NavBarPopover.tsx b/examples/algolia/src/components/header/navigation/NavBarPopover.tsx index b7b445c3..1409d5c7 100644 --- a/examples/algolia/src/components/header/navigation/NavBarPopover.tsx +++ b/examples/algolia/src/components/header/navigation/NavBarPopover.tsx @@ -1,44 +1,86 @@ "use client"; -import { Popover, Transition } from "@headlessui/react"; -import { Fragment, ReactElement } from "react"; -import NavItemContent from "./NavItemContent"; +import { ReactElement } from "react"; import { NavigationNode } from "../../../lib/build-site-navigation"; +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, +} from "../../navigation-menu/NavigationMenu"; +import Link from "next/link"; +import { ArrowRightIcon } from "@heroicons/react/20/solid"; export function NavBarPopover({ nav, }: { nav: NavigationNode[]; }): ReactElement { + const buildStack = (item: NavigationNode) => { + return ( +
+ {item.name} + {item.children.map((child: NavigationNode) => ( + + + {child.name} + + + ))} + + + Browse All + + +
+ ); + }; + return ( <> - {nav && - nav.map((item: NavigationNode) => ( - - {({ close }) => ( - <> - - {item.name} - - - - -
- + {nav && ( + + + {nav.map((item: NavigationNode) => { + return ( + + + {item.name} + + +
+
+ {item.children.map( + (parent: NavigationNode, index: number) => { + return
{buildStack(parent)}
; + }, + )} +
+
+ + + Browse All {item.name} + + +
- - - - )} - - ))} +
+
+ ); + })} +
+
+ )} ); } diff --git a/examples/algolia/src/components/header/navigation/NavItemContent.tsx b/examples/algolia/src/components/header/navigation/NavItemContent.tsx index de3ee02c..dec7fce9 100644 --- a/examples/algolia/src/components/header/navigation/NavItemContent.tsx +++ b/examples/algolia/src/components/header/navigation/NavItemContent.tsx @@ -4,10 +4,10 @@ import { ArrowRightIcon } from "@heroicons/react/20/solid"; interface IProps { item: NavigationNode; - onClose: () => void; + setOpen?: (open: boolean) => void; } -const NavItemContent = ({ item, onClose }: IProps): JSX.Element => { +const NavItemContent = ({ item, setOpen }: IProps): JSX.Element => { const buildStack = (item: NavigationNode) => { return (
@@ -16,18 +16,18 @@ const NavItemContent = ({ item, onClose }: IProps): JSX.Element => { setOpen && setOpen(false)} passHref - className="link-hover" - onClick={() => onClose()} + className="hover:text-brand-primary hover:underline" > {child.name} ))} setOpen && setOpen(false)} passHref - className="link-hover font-semibold" - onClick={() => onClose()} + className="hover:text-brand-primary hover:underline font-semibold" > Browse All @@ -44,10 +44,10 @@ const NavItemContent = ({ item, onClose }: IProps): JSX.Element => {

setOpen && setOpen(false)} passHref - onClick={() => onClose()} > Browse All {item.name} diff --git a/examples/algolia/src/components/header/navigation/NavMenu.tsx b/examples/algolia/src/components/header/navigation/NavMenu.tsx index c22dd9ca..111986db 100644 --- a/examples/algolia/src/components/header/navigation/NavMenu.tsx +++ b/examples/algolia/src/components/header/navigation/NavMenu.tsx @@ -1,114 +1,47 @@ "use client"; -import { Dispatch, SetStateAction, Fragment, useState } from "react"; -import { Transition, Dialog, Disclosure } from "@headlessui/react"; import { NavigationNode } from "@elasticpath/react-shopper-hooks"; import { ChevronUpIcon } from "@heroicons/react/20/solid"; import NavItemContent from "./NavItemContent"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "../../accordion/Accordion"; +import { cn } from "../../../lib/cn"; interface IProps { - showMenu: boolean; - setShowMenu: Dispatch>; nav: NavigationNode[]; + setOpen: (open: boolean) => void; } -const NavMenu = (props: IProps) => { - const [expandedDisclosure, setExpandedDisclosure] = useState({ - index: 0, - open: false, - }); - - // If clicked disclosure isn't the same as the open one, close all other disclosures - function handleDisclosureChange(state: number) { - if (state !== expandedDisclosure.index) { - const panels = [ - ...document.querySelectorAll( - "[aria-expanded=true][aria-label=panel]", - ), - ]; - panels.map((panel) => panel.click()); - setExpandedDisclosure({ index: state, open: true }); - } - } - +const NavMenu = ({ nav, setOpen }: IProps) => { return ( - - props.setShowMenu(false)} - > - - -
- -
- -
- {props.nav && - props.nav.map((item, index) => { - return ( - - {({ open }) => ( - <> - handleDisclosureChange(index)} - > - {item.name} - - - - props.setShowMenu(false)} - /> - - - )} - - ); - })} -
-
-
-
-
+ {item.name} + + + + + + + + ); + })} +
); }; diff --git a/examples/algolia/src/components/input/Input.tsx b/examples/algolia/src/components/input/Input.tsx new file mode 100644 index 00000000..fc987fd8 --- /dev/null +++ b/examples/algolia/src/components/input/Input.tsx @@ -0,0 +1,41 @@ +import { cn } from "../../lib/cn"; +import { forwardRef, InputHTMLAttributes } from "react"; +import { cva, VariantProps } from "class-variance-authority"; + +const inputVariants = cva( + "flex w-full text-black/80 rounded-lg border border-input border-black/40 focus-visible:ring-0 focus-visible:border-black bg-background disabled:cursor-not-allowed disabled:opacity-50 leading-[1.6rem]", + { + variants: { + sizeKind: { + default: "px-4 py-[0.78rem]", + medium: "px-4 py-[0.44rem]", + mediumUntilSm: "px-4 py-[0.44rem] sm:px-4 sm:py-[0.78rem]", + }, + }, + defaultVariants: { + sizeKind: "default", + }, + }, +); + +export interface InputProps + extends InputHTMLAttributes, + VariantProps { + asChild?: boolean; +} + +const Input = forwardRef( + ({ className, sizeKind, type, ...props }, ref) => { + return ( + + ); + }, +); +Input.displayName = "Input"; + +export { Input }; diff --git a/examples/algolia/src/components/label/Label.tsx b/examples/algolia/src/components/label/Label.tsx new file mode 100644 index 00000000..2d39d22b --- /dev/null +++ b/examples/algolia/src/components/label/Label.tsx @@ -0,0 +1,18 @@ +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "../../lib/cn"; +import { forwardRef, LabelHTMLAttributes } from "react"; + +const labelVariants = cva( + "text-sm font-medium text-black/80 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70", +); +export interface LabelProps + extends LabelHTMLAttributes, + VariantProps {} +const Label = forwardRef( + ({ className, ...props }, ref) => ( +