diff --git a/components/index.js b/components/index.js new file mode 100644 index 0000000..06345fd --- /dev/null +++ b/components/index.js @@ -0,0 +1,21 @@ +import Marketplace from './marketplace'; +import ProductPage from './productPage'; + +const NewfoldMarketplace = ( { methods, constants, ...props } ) => { + const match = methods.useMatch( 'marketplace/product/:id' ); + if ( match ) { + return ( + + ); + } + + return ( + + ); +}; + +export default NewfoldMarketplace; diff --git a/components/marketplaceIsLoading/MarketplaceFilterBarSkeleton.js b/components/marketplaceIsLoading/MarketplaceFilterBarSkeleton.js index 3dc82a2..166ad4a 100644 --- a/components/marketplaceIsLoading/MarketplaceFilterBarSkeleton.js +++ b/components/marketplaceIsLoading/MarketplaceFilterBarSkeleton.js @@ -9,7 +9,7 @@ import MarketplaceSkeleton from "../marketplaceSkeleton"; */ const MarketplaceFilterBarSkeleton = ({ width, height }) => { return ( - + ); } diff --git a/components/marketplaceIsLoading/MarketplaceItemSkeleton.js b/components/marketplaceIsLoading/MarketplaceItemSkeleton.js index 751b6d2..ec0ce28 100644 --- a/components/marketplaceIsLoading/MarketplaceItemSkeleton.js +++ b/components/marketplaceIsLoading/MarketplaceItemSkeleton.js @@ -12,17 +12,17 @@ const MarketplaceItemSkeleton = () => {
- +
- + - - - + + + - +
diff --git a/components/marketplaceItem/index.js b/components/marketplaceItem/index.js index 74f6740..eb62bb2 100644 --- a/components/marketplaceItem/index.js +++ b/components/marketplaceItem/index.js @@ -1,3 +1,4 @@ +import { validate as isUuid } from 'uuid'; import { Button, Card, Link, Title } from '@newfold/ui-component-library'; import { ArrowRightIcon } from '@heroicons/react/24/outline'; @@ -150,6 +151,54 @@ const MarketplaceItem = ( { item, methods, constants } ) => { return primaryCTA; }; + const renderSecondaryCTA = ( item ) => { + let secondaryCTA = ''; + // If value is UUID, it is an internal link to a prodct page + const isInternal = isUuid( item.secondaryUrl ); + + const getLinkAttributes = () => { + const attributes = { + as: 'a', + className: + 'nfd-inline-flex nfd-items-center nfd-gap-1.5 nfd-w-max nfd-no-underline', + href: generateSecondaryUrl(), + target: isInternal ? '_self' : '_blank', + onClick: handleNavigate, + }; + + return attributes; + }; + + const handleNavigate = ( e ) => { + const navigate = methods.useNavigate(); + if ( isInternal ) { + e.preventDefault(); + navigate( `/marketplace/product/${ item.secondaryUrl }` ); + } + }; + + const generateSecondaryUrl = () => { + if ( isInternal ) { + return `${ window.NewfoldRuntime.admin_url }admin.php?page=bluehost#/marketplace/product/${ item.secondaryUrl }`; + } + + return item.secondaryUrl; + }; + + if ( item.secondaryCallToAction && item.secondaryUrl ) { + secondaryCTA = ( + + + { item.secondaryCallToAction } + + + + ); + } + + return secondaryCTA; + }; + const renderPrice = ( item ) => { let pricewrap, price, @@ -198,20 +247,7 @@ const MarketplaceItem = ( { item, methods, constants } ) => { { item.name }

{ item.description }

- - { item.secondaryCallToAction && ( - - - { item.secondaryCallToAction } - - - - ) } + { renderSecondaryCTA( item ) } { renderPrice( item ) } diff --git a/components/marketplaceSkeleton/index.js b/components/marketplaceSkeleton/index.js index de8b17d..39522ca 100644 --- a/components/marketplaceSkeleton/index.js +++ b/components/marketplaceSkeleton/index.js @@ -2,21 +2,25 @@ import './stylesheet.scss'; /** * MarketplaceSkeleton Component - * Use to generate content skeleton - * - * @param {*} props - * @returns + * Use to generate content loading skeleton + * + * @param {*} props + * @return {JSX.Element} MarketplaceSkeleton */ -const MarketplaceSkeleton = ({ width, height, customClass }) => { - return ( -
-
- ); -} - -export default MarketplaceSkeleton; \ No newline at end of file +const MarketplaceSkeleton = ( { width, height, className = '' } ) => { + return ( +
+ ); +}; + +export default MarketplaceSkeleton; diff --git a/components/marketplaceSkeleton/stylesheet.scss b/components/marketplaceSkeleton/stylesheet.scss index 8a0e794..9d28d37 100644 --- a/components/marketplaceSkeleton/stylesheet.scss +++ b/components/marketplaceSkeleton/stylesheet.scss @@ -2,7 +2,7 @@ position: relative; background-color: rgba(160, 170, 192, .35); overflow: hidden; - border-radius: 2px; + border-radius: 4px; &::after { position: absolute; diff --git a/components/productPage/ProductPageError.js b/components/productPage/ProductPageError.js new file mode 100644 index 0000000..de37dbe --- /dev/null +++ b/components/productPage/ProductPageError.js @@ -0,0 +1,32 @@ +import { Title } from '@newfold/ui-component-library'; +import errorVector from '../../includes/assets/img/dog-walking.svg'; + +const ProductPageError = ( { text = {} } ) => { + return ( +
+
+ Dog walking with a leash +
+ + { text?.title ?? 'Oops! Something Went Wrong' } + +

+ { text?.description ?? + 'An error occurred while loading the content. Please try again later.' } +

+
+
+
+ ); +}; + +export default ProductPageError; diff --git a/components/productPage/ProductPageLoading.js b/components/productPage/ProductPageLoading.js new file mode 100644 index 0000000..a5d3e9a --- /dev/null +++ b/components/productPage/ProductPageLoading.js @@ -0,0 +1,129 @@ +import MarketplaceSkeleton from '../marketplaceSkeleton'; + +const ProductPageLoading = () => { + const Hero = () => ( +
+ +
+ + + +
+
+ ); + + const Features = () => ( +
+ + + +
+ + + +
+
+ ); + + const Feature = () => ( +
+ + +
+ + + +
+
+ ); + + const Pricing = () => ( + <> +
+
+
+
+ +
+ + + +
+
+ + ); + + const PricingItem = () => ( +
+ +
+ + + + + + +
+ +
+ ); + + const FAQ = () => ( +
+ +
+ + + + +
+
+ + + + +
+ ); + + const FAQitem = ( { width = '100%', children } ) => ( +
+ + { children } +
+ ); + + return ( +
+ + + + +
+ ); +}; + +export default ProductPageLoading; diff --git a/components/productPage/index.js b/components/productPage/index.js new file mode 100644 index 0000000..a41be50 --- /dev/null +++ b/components/productPage/index.js @@ -0,0 +1,74 @@ +import ProductPageError from './ProductPageError'; +import ProductPageLoading from './ProductPageLoading'; + +const initialState = { + html: null, + loading: true, + error: false, +}; + +const ProductPage = ( { productPageId, methods, constants } ) => { + const [ data, setData ] = methods.useState( { + ...initialState, + } ); + + methods.useEffect( () => { + // Reset the state + setData( { + ...initialState, + } ); + + methods + .apiFetch( { + url: methods.NewfoldRuntime.createApiUrl( + `/newfold-marketplace/v1/products/page`, + { id: productPageId } + ), + } ) + .then( ( response ) => { + if ( response.hasOwnProperty( 'html' ) ) { + // Set the html content + setData( { + html: response.html, + loading: false, + error: false, + } ); + } else { + // Invoke error state + setData( { + html: null, + loading: false, + error: true, + } ); + } + } ) + .catch( () => { + // Invoke error state + setData( { + html: null, + loading: false, + error: true, + } ); + } ); + }, [ productPageId ] ); + + return ( +
+ { data.loading && } + { data.error && ( + + ) } + { data.html && ( +
+ ) } +
+ ); +}; + +export default ProductPage; diff --git a/includes/MarketplaceApi.php b/includes/MarketplaceApi.php index fd40e8e..9d8bf66 100644 --- a/includes/MarketplaceApi.php +++ b/includes/MarketplaceApi.php @@ -32,6 +32,18 @@ public static function registerRoutes() { ) ); + register_rest_route( + 'newfold-marketplace/v1', + '/products/page', + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => __CLASS__ . '::product_page_callback', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + ) + ); + } /** @@ -176,4 +188,39 @@ public static function category_data( $args ) { return false; } + + /** + * Get product page data + * + * @param \WP_REST_Request $request Request object. + * + * @return \WP_Error|\WP_HTTP_Response|\WP_REST_Response + */ + public static function product_page_callback( \WP_REST_Request $request ) { + $page = $request->get_param( 'id' ); + $product_pages_ednpoint = NFD_HIIVE_URL . '/marketplace/v1/products/pages/' . $page; + $data = array(); + + $response = wp_remote_get( + $product_pages_ednpoint, + array( + 'headers' => array( + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ), + ) + ); + + if ( + ! is_wp_error( $response ) + && 200 === wp_remote_retrieve_response_code( $response ) + ) { + $body = wp_remote_retrieve_body( $response ); + if ( $body ) { + $data['html'] = $body; + } + } + + return rest_ensure_response( $data ); + } } diff --git a/includes/assets/img/dog-walking.svg b/includes/assets/img/dog-walking.svg new file mode 100644 index 0000000..2d78fc6 --- /dev/null +++ b/includes/assets/img/dog-walking.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/includes/assets/js/NFDPluginsMarketplace.js b/includes/assets/js/NFDPluginsMarketplace.js index f4981dc..c560581 100644 --- a/includes/assets/js/NFDPluginsMarketplace.js +++ b/includes/assets/js/NFDPluginsMarketplace.js @@ -102,7 +102,7 @@ class NFDPluginsMarketplace { actionButtons.push(primaryAction); } - if (product.secondaryUrl) { + if (product.secondaryUrl && this.isValidUrl(product.secondaryUrl)) { const secondaryAction = `More Details`; actionButtons.push(secondaryAction); @@ -230,6 +230,16 @@ class NFDPluginsMarketplace { } }); } + + // Check if a string is a valid URL + isValidUrl(value) { + try { + new URL(value); + return true; + } catch (e) { + return false; + } + } } const nfdPluginsMarketplace = new NFDPluginsMarketplace(); diff --git a/package-lock.json b/package-lock.json index a63fd38..a69f962 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "license": "GPL-2.0-or-later", "dependencies": { "@heroicons/react": "^2.1.3", - "@newfold/ui-component-library": "^1.1.0" + "@newfold/ui-component-library": "^1.1.0", + "uuid": "^9.0.1" } }, "node_modules/@babel/runtime": { @@ -682,6 +683,18 @@ "dependencies": { "tslib": "^2.0.3" } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } } }, "dependencies": { @@ -1226,6 +1239,11 @@ "requires": { "tslib": "^2.0.3" } + }, + "uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" } } } diff --git a/package.json b/package.json index 92c944f..46e1c69 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ ], "dependencies": { "@heroicons/react": "^2.1.3", - "@newfold/ui-component-library": "^1.1.0" + "@newfold/ui-component-library": "^1.1.0", + "uuid": "^9.0.1" } } diff --git a/tests/cypress/fixtures/marketplace-products.json b/tests/cypress/fixtures/marketplace-products.json index e1ecbda..ab574c2 100644 --- a/tests/cypress/fixtures/marketplace-products.json +++ b/tests/cypress/fixtures/marketplace-products.json @@ -108,6 +108,32 @@ }, "price_formatted": "$99.00" }, + { + "id": "a2ff70f1-9670-4e25-a0e1-a068d3e43d55", + "name": "Yoast Premium (Product Page)", + "version": null, + "description": "SEO made easy! Improve your ranking in search engines, boost performance and visibility, get social previews, a redirect manager, internal linking suggestions and 24\/7 premium support.", + "productThumbnailUrl": "https:\/\/cdn.hiive.space\/products\/thumbs\/Yoast-PRO.webp", + "primaryUrl": "https:\/\/yoa.st\/bh-premium?utm_source=wp-admin%2Fadmin.php&utm_medium=brand_plugin", + "primaryCallToAction": "Buy Now", + "secondaryUrl": "549e5e29-735f-4e09-892e-766ca9b59858", + "secondaryCallToAction": "Learn More", + "clickToBuyId": "57d6a568-783c-45e2-a388-847cff155897", + "type": "plugin", + "price": "99.00", + "vendor_id": "03e93b44-9a95-400f-bc0b-f53c2bfa91cf", + "priority": 100, + "createdAt": "2022-10-07T15:37:40.000000Z", + "updatedAt": "2022-10-07T15:37:40.000000Z", + "categories": [ + "Featured" + ], + "vendor": { + "name": "Yoast", + "url": "https:\/\/yoast.com\/" + }, + "price_formatted": "$99.00" + }, { "id": "2a1dadb5-f58d-4ae4-a26b-27efb09136eb", "name": "Highend", diff --git a/tests/cypress/fixtures/product-page.json b/tests/cypress/fixtures/product-page.json new file mode 100644 index 0000000..9aa3d00 --- /dev/null +++ b/tests/cypress/fixtures/product-page.json @@ -0,0 +1,3 @@ +{ + "html": "
\n

Single Product Page Title<\/h2>\n

This is a premium plugin for $99\/yr<\/p>\n<\/div>" +} \ No newline at end of file diff --git a/tests/cypress/integration/marketplace.cy.js b/tests/cypress/integration/marketplace.cy.js index 2792c52..e149aa5 100644 --- a/tests/cypress/integration/marketplace.cy.js +++ b/tests/cypress/integration/marketplace.cy.js @@ -33,8 +33,8 @@ describe( 'Marketplace Page', function () { cy.checkA11y( appClass + '-app-body' ); } ); - it( 'Product grid has 4 items', () => { - cy.get( '.marketplace-item' ).should( 'have.length', 4 ); + it( 'Product grid has 5 items', () => { + cy.get( '.marketplace-item' ).should( 'have.length', 5 ); } ); it( 'First product card renders correctly', () => { @@ -105,6 +105,25 @@ describe( 'Marketplace Page', function () { .and( 'include', '_blank' ); } ); + it( 'Product page Secondary CTA links properly', () => { + cy.get( '#marketplace-item-a2ff70f1-9670-4e25-a0e1-a068d3e43d55' ).as( + 'card' + ); + + cy.get( '@card' ) + .findByRole( 'link', { name: 'Learn More' } ) + .scrollIntoView() + .should( 'be.visible' ) + .and( 'have.attr', 'target', '_self' ) + .and( 'have.attr', 'href' ) + .and( + 'include', + 'page=' + + Cypress.env( 'pluginId' ) + + '#/marketplace/product/549e5e29-735f-4e09-892e-766ca9b59858' + ); + } ); + it( 'Category Tab Filters properly', () => { cy.get( appClass + '-app-subnavitem-Services' ).click(); cy.get( '.marketplace-item' ).should( 'have.length', 12 ); diff --git a/tests/cypress/integration/product-page.cy.js b/tests/cypress/integration/product-page.cy.js new file mode 100644 index 0000000..2e5008d --- /dev/null +++ b/tests/cypress/integration/product-page.cy.js @@ -0,0 +1,57 @@ +const productPageFixtures = require( '../fixtures/product-page.json' ); + +describe( 'Product Page', function () { + beforeEach( () => { + cy.intercept( + { + method: 'GET', + url: /newfold-marketplace(\/|%2F)v1(\/|%2F)products(\/|%2F)page/, + }, + { + body: productPageFixtures, + delay: 250, + } + ).as( 'productPageData' ); + + cy.visit( + '/wp-admin/admin.php?page=' + + Cypress.env( 'pluginId' ) + + '#/marketplace/product/549e5e29-735f-4e09-892e-766ca9b59858' + ); + } ); + + it( 'Show loading state while fetching', () => { + cy.get( + '.wppbh-app-marketplace-container div[aria-label="Fetching product details"]' + ).should( 'be.visible' ); + } ); + + it( 'Product page content is visible', () => { + cy.wait( '@productPageData' ); + cy.get( '.nfd-product-page-content' ).should( 'be.visible' ); + } ); + + it( 'Show error state if fetching fails', () => { + cy.reload(); + cy.intercept( + { + method: 'GET', + url: /newfold-marketplace(\/|%2F)v1(\/|%2F)products(\/|%2F)page/, + }, + { + status: 404, + body: 'Error', + delay: 250, + } + ).as( 'productPageError' ); + cy.wait( '@productPageError' ); + cy.get( '.wppbh-app-marketplace-container div[role="alert"]' ) + .find( 'img[alt="Dog walking with a leash"]' ) + .should( 'exist' ) + .and( 'be.visible' ); + cy.contains( 'Oops! Something Went Wrong' ).should( 'be.visible' ); + cy.contains( + 'An error occurred while loading the content. Please try again later.' + ).should( 'be.visible' ); + } ); +} );