diff --git a/app/controllers/api/internal/carts_controller.rb b/app/controllers/checkout/carts_controller.rb similarity index 98% rename from app/controllers/api/internal/carts_controller.rb rename to app/controllers/checkout/carts_controller.rb index a1b8eadd39..108902b9d0 100644 --- a/app/controllers/api/internal/carts_controller.rb +++ b/app/controllers/checkout/carts_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::Internal::CartsController < Api::Internal::BaseController +class Checkout::CartsController < ApplicationController def update if permitted_cart_params[:items].length > Cart::MAX_ALLOWED_CART_PRODUCTS return render json: { error: "You cannot add more than #{Cart::MAX_ALLOWED_CART_PRODUCTS} products to the cart." }, status: :unprocessable_entity diff --git a/app/controllers/checkout_controller.rb b/app/controllers/checkout_controller.rb index 933f3c1cfb..4779e020ae 100644 --- a/app/controllers/checkout_controller.rb +++ b/app/controllers/checkout_controller.rb @@ -3,9 +3,13 @@ class CheckoutController < ApplicationController before_action :process_cart_id_param + layout "inertia" + def index - @hide_layouts = true - @checkout_presenter = CheckoutPresenter.new(logged_in_user:, ip: request.remote_ip) + render inertia: "Checkout/Index", props: CheckoutPresenter.new(logged_in_user:, ip: request.remote_ip).checkout_props( + params:, + browser_guid: cookies[:_gumroad_guid] + ) end private diff --git a/app/javascript/components/Checkout/TemporaryLibrary.tsx b/app/javascript/components/Checkout/TemporaryLibrary.tsx index a799b747bf..249e3b57d5 100644 --- a/app/javascript/components/Checkout/TemporaryLibrary.tsx +++ b/app/javascript/components/Checkout/TemporaryLibrary.tsx @@ -3,13 +3,13 @@ import * as React from "react"; import { ProductNativeType } from "$app/parsers/product"; import { Creator } from "$app/components/Checkout/cartState"; +import { Result } from "$app/components/Checkout/modals"; import { useState } from "$app/components/Checkout/payment"; import { CreateAccountForm } from "$app/components/Checkout/Receipt"; import { useLoggedInUser } from "$app/components/LoggedInUser"; import { AuthorByline } from "$app/components/Product/AuthorByline"; import { Thumbnail } from "$app/components/Product/Thumbnail"; import { showAlert } from "$app/components/server-components/Alert"; -import { Result } from "$app/components/server-components/CheckoutPage"; import { Card as UICard, CardContent } from "$app/components/ui/Card"; import { PageHeader } from "$app/components/ui/PageHeader"; import { ProductCard, ProductCardFigure, ProductCardHeader, ProductCardFooter } from "$app/components/ui/ProductCard"; diff --git a/app/javascript/components/Checkout/cartState.ts b/app/javascript/components/Checkout/cartState.ts index 07620d0031..fdba039292 100644 --- a/app/javascript/components/Checkout/cartState.ts +++ b/app/javascript/components/Checkout/cartState.ts @@ -181,7 +181,7 @@ export function newCartState(): CartState { export async function saveCartState(cart: CartState) { const response = await request({ method: "PUT", - url: Routes.internal_cart_path(), + url: Routes.checkout_cart_path(), accept: "json", data: { cart }, }); diff --git a/app/javascript/components/Checkout/modals.tsx b/app/javascript/components/Checkout/modals.tsx new file mode 100644 index 0000000000..e092e81086 --- /dev/null +++ b/app/javascript/components/Checkout/modals.tsx @@ -0,0 +1,143 @@ +import * as React from "react"; + +import { LineItemResult } from "$app/data/purchase"; +import { formatOrderOfMagnitude } from "$app/utils/formatOrderOfMagnitude"; + +import { Button } from "$app/components/Button"; +import { CartItem, CartState, CrossSell, Upsell, getDiscountedPrice } from "$app/components/Checkout/cartState"; +import { AuthorByline } from "$app/components/Product/AuthorByline"; +import { computeOptionPrice, OptionRadioButton, Option } from "$app/components/Product/ConfigurationSelector"; +import { PriceTag } from "$app/components/Product/PriceTag"; +import { ProductCard, ProductCardFigure, ProductCardHeader, ProductCardFooter } from "$app/components/ui/ProductCard"; + +export type Result = { item: CartItem; result: LineItemResult }; + +export const CrossSellModal = ({ + crossSell, + decline, + accept, + cart, +}: { + crossSell: CrossSell; + accept: () => void; + decline: () => void; + cart: CartState; +}) => { + const product = crossSell.offered_product.product; + const option = product.options.find(({ id }) => id === crossSell.offered_product.option_id); + + const crossSellCartItem: CartItem = { + ...crossSell.offered_product, + quantity: crossSell.offered_product.quantity || 1, + url_parameters: {}, + referrer: "", + recommender_model_name: null, + accepted_offer: crossSell.discount ? { id: crossSell.id, discount: crossSell.discount } : null, + }; + const { price: discountedPrice } = getDiscountedPrice(cart, crossSellCartItem); + + return ( + <> +
+

+ + + {product.thumbnail_url ? : null} + +
+ + +

{option ? `${product.name} - ${option.name}` : product.name}

+
+ +
+ + {crossSell.ratings ? ( +
+ {crossSell.ratings.average.toFixed(1)} + {`(${formatOrderOfMagnitude(crossSell.ratings.count, 1)})`} +
+ ) : null} +
+ +
+
+
+
+

+ + + ); +}; + +export type OfferedUpsell = Upsell & { item: CartItem; offeredOption: Option }; +export const UpsellModal = ({ + upsell, + accept, + decline, + cart, +}: { + upsell: OfferedUpsell; + accept: () => void; + decline: () => void; + cart: CartState; +}) => { + const { item, offeredOption } = upsell; + const product = item.product; + const { discount } = getDiscountedPrice(cart, { ...item, option_id: offeredOption.id }); + return ( + <> +
+

+
+ +
+

+ + + ); +}; diff --git a/app/javascript/components/CheckoutDashboard/UpsellsPage.tsx b/app/javascript/components/CheckoutDashboard/UpsellsPage.tsx index f7237a173e..5499208644 100644 --- a/app/javascript/components/CheckoutDashboard/UpsellsPage.tsx +++ b/app/javascript/components/CheckoutDashboard/UpsellsPage.tsx @@ -22,6 +22,7 @@ import { AbortError, assertResponseError } from "$app/utils/request"; import { Button } from "$app/components/Button"; import { ProductToAdd, CartItem } from "$app/components/Checkout/cartState"; +import { CrossSellModal, UpsellModal } from "$app/components/Checkout/modals"; import { CheckoutPreview } from "$app/components/CheckoutDashboard/CheckoutPreview"; import { DiscountInput, InputtedDiscount } from "$app/components/CheckoutDashboard/DiscountInput"; import { Layout, Page } from "$app/components/CheckoutDashboard/Layout"; @@ -36,7 +37,6 @@ import { applySelection } from "$app/components/Product/ConfigurationSelector"; import { Search } from "$app/components/Search"; import { Select } from "$app/components/Select"; import { showAlert } from "$app/components/server-components/Alert"; -import { CrossSellModal, UpsellModal } from "$app/components/server-components/CheckoutPage"; import { Skeleton } from "$app/components/Skeleton"; import { Card, CardContent } from "$app/components/ui/Card"; import { PageHeader } from "$app/components/ui/PageHeader"; diff --git a/app/javascript/packs/checkout.ts b/app/javascript/packs/checkout.ts deleted file mode 100644 index 42cd524cd2..0000000000 --- a/app/javascript/packs/checkout.ts +++ /dev/null @@ -1,9 +0,0 @@ -import ReactOnRails from "react-on-rails"; - -import BasePage from "$app/utils/base_page"; - -import CheckoutPage from "$app/components/server-components/CheckoutPage"; - -BasePage.initialize(); - -ReactOnRails.register({ CheckoutPage }); diff --git a/app/javascript/components/server-components/CheckoutPage.tsx b/app/javascript/pages/Checkout/Index.tsx similarity index 79% rename from app/javascript/components/server-components/CheckoutPage.tsx rename to app/javascript/pages/Checkout/Index.tsx index 58666918d3..65cc17b7bf 100644 --- a/app/javascript/components/server-components/CheckoutPage.tsx +++ b/app/javascript/pages/Checkout/Index.tsx @@ -1,10 +1,10 @@ +import { usePage } from "@inertiajs/react"; import { reverse } from "lodash-es"; import * as React from "react"; -import { createCast, cast } from "ts-safe-cast"; +import { cast } from "ts-safe-cast"; import { SurchargesResponse } from "$app/data/customer_surcharge"; import { startOrderCreation } from "$app/data/order"; -import { LineItemResult } from "$app/data/purchase"; import { getPlugins, trackUserActionEvent, trackUserProductAction } from "$app/data/user_action_event"; import { SavedCreditCard } from "$app/parsers/card"; import { CardProduct, COMMISSION_DEPOSIT_PROPORTION, CustomFieldDescriptor } from "$app/parsers/product"; @@ -12,14 +12,11 @@ import { isOpenTuple } from "$app/utils/array"; import { assert } from "$app/utils/assert"; import { getIsSingleUnitCurrency } from "$app/utils/currency"; import { isValidEmail } from "$app/utils/email"; -import { formatOrderOfMagnitude } from "$app/utils/formatOrderOfMagnitude"; import { calculateFirstInstallmentPaymentPriceCents } from "$app/utils/price"; import { asyncVoid } from "$app/utils/promise"; import { assertResponseError } from "$app/utils/request"; -import { register } from "$app/utils/serverComponentUtil"; import { startTrackingForSeller, trackProductEvent } from "$app/utils/user_analytics"; -import { Button } from "$app/components/Button"; import { Checkout } from "$app/components/Checkout"; import { CartItem, @@ -27,12 +24,12 @@ import { convertToUSD, findCartItem, getDiscountedPrice, - Upsell, ProductToAdd, CrossSell, saveCartState, newCartState, } from "$app/components/Checkout/cartState"; +import { CrossSellModal, UpsellModal, Result, OfferedUpsell } from "$app/components/Checkout/modals"; import { StateContext, createReducer, @@ -48,11 +45,8 @@ import { TemporaryLibrary } from "$app/components/Checkout/TemporaryLibrary"; import { useFeatureFlags } from "$app/components/FeatureFlags"; import { useLoggedInUser } from "$app/components/LoggedInUser"; import { Modal } from "$app/components/Modal"; -import { AuthorByline } from "$app/components/Product/AuthorByline"; -import { computeOptionPrice, OptionRadioButton, Option } from "$app/components/Product/ConfigurationSelector"; -import { PriceTag } from "$app/components/Product/PriceTag"; +import { computeOptionPrice } from "$app/components/Product/ConfigurationSelector"; import { showAlert } from "$app/components/server-components/Alert"; -import { ProductCard, ProductCardFigure, ProductCardHeader, ProductCardFooter } from "$app/components/ui/ProductCard"; import { useAddThirdPartyAnalytics } from "$app/components/useAddThirdPartyAnalytics"; import { useDebouncedCallback } from "$app/components/useDebouncedCallback"; import { useOnChange, useOnChangeSync } from "$app/components/useOnChange"; @@ -92,8 +86,6 @@ type Props = { default_tip_option: number; }; -export type Result = { item: CartItem; result: LineItemResult }; - function getCartItemUid(item: CartItem) { return `${item.product.permalink} ${item.option_id ?? ""}`; } @@ -139,25 +131,27 @@ const addProduct = ({ else cart.items.unshift(newItem); }; -export const CheckoutPage = ({ - discover_url, - countries, - us_states, - ca_provinces, - country, - state: addressState, - address, - clear_cart, - add_products, - gift, - saved_credit_card, - recaptcha_key, - paypal_client_id, - max_allowed_cart_products, - tip_options, - default_tip_option, - ...props -}: Props) => { +const CheckoutPage = () => { + const { + discover_url, + countries, + us_states, + ca_provinces, + country, + state: addressState, + address, + clear_cart, + add_products, + gift, + saved_credit_card, + recaptcha_key, + paypal_client_id, + max_allowed_cart_products, + tip_options, + default_tip_option, + ...props + } = cast(usePage().props); + const user = useLoggedInUser(); const email = props.cart?.email ?? user?.email ?? ""; const [cart, setCart] = React.useState(() => { @@ -646,134 +640,5 @@ export const CheckoutPage = ({ ); }; -export const CrossSellModal = ({ - crossSell, - decline, - accept, - cart, -}: { - crossSell: CrossSell; - accept: () => void; - decline: () => void; - cart: CartState; -}) => { - const product = crossSell.offered_product.product; - const option = product.options.find(({ id }) => id === crossSell.offered_product.option_id); - - const crossSellCartItem: CartItem = { - ...crossSell.offered_product, - quantity: crossSell.offered_product.quantity || 1, - url_parameters: {}, - referrer: "", - recommender_model_name: null, - accepted_offer: crossSell.discount ? { id: crossSell.id, discount: crossSell.discount } : null, - }; - const { price: discountedPrice } = getDiscountedPrice(cart, crossSellCartItem); - - return ( - <> -
-

- - - {product.thumbnail_url ? : null} - -
- - -

{option ? `${product.name} - ${option.name}` : product.name}

-
- -
- - {crossSell.ratings ? ( -
- {crossSell.ratings.average.toFixed(1)} - {`(${formatOrderOfMagnitude(crossSell.ratings.count, 1)})`} -
- ) : null} -
- -
-
-
-
-

-
- - -
- - ); -}; - -type OfferedUpsell = Upsell & { item: CartItem; offeredOption: Option }; -export const UpsellModal = ({ - upsell, - accept, - decline, - cart, -}: { - upsell: OfferedUpsell; - accept: () => void; - decline: () => void; - cart: CartState; -}) => { - const { item, offeredOption } = upsell; - const product = item.product; - const { discount } = getDiscountedPrice(cart, { ...item, option_id: offeredOption.id }); - return ( - <> -
-

-
- -
-

- - - ); -}; - -export default register({ component: CheckoutPage, propParser: createCast() }); +CheckoutPage.loggedInUserLayout = true; +export default CheckoutPage; diff --git a/app/javascript/ssr.ts b/app/javascript/ssr.ts index 4a9538e1d5..d90775db27 100644 --- a/app/javascript/ssr.ts +++ b/app/javascript/ssr.ts @@ -4,7 +4,7 @@ import "whatwg-fetch"; import ReactOnRails from "react-on-rails"; import Alert from "$app/components/server-components/Alert"; -import CheckoutPage from "$app/components/server-components/CheckoutPage"; +import BundleEditPage from "$app/components/server-components/BundleEditPage"; import CommunitiesPage from "$app/components/server-components/CommunitiesPage"; import CustomersDownloadPopover from "$app/components/server-components/CustomersPage/DownloadPopover"; import CustomersFilterPopover from "$app/components/server-components/CustomersPage/FilterPopover"; @@ -38,7 +38,7 @@ import { Pill } from "$app/components/ui/Pill"; ReactOnRails.register({ Alert, SupportHeader, - CheckoutPage, + BundleEditPage, CodeSnippet, CommunitiesPage, CustomersDownloadPopover, diff --git a/app/views/checkout/index.html.erb b/app/views/checkout/index.html.erb deleted file mode 100644 index 71146e064f..0000000000 --- a/app/views/checkout/index.html.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= render("shared/recaptcha_script") %> -<%= load_pack("checkout") %> -<%= react_component "CheckoutPage", props: @checkout_presenter.checkout_props(params:, browser_guid: cookies[:_gumroad_guid]), prerender: false %> diff --git a/config/routes.rb b/config/routes.rb index df76c929b2..33a4102e3d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -576,6 +576,7 @@ def product_info_and_purchase_routes(named_routes: true) get "/purchases/search", to: "purchases#search" resources :checkout, only: [:index] + resource :checkout_cart, only: [:update], controller: "checkout/carts", path: "checkout/cart" resources :licenses, only: [:update] @@ -907,7 +908,6 @@ def product_info_and_purchase_routes(named_routes: true) resource :recipient_count, only: [:show], controller: "installments/recipient_counts", as: :installment_recipient_count end end - resource :cart, only: [:update] resources :products, only: [:show] do resources :product_posts, only: [:index] resources :existing_product_files, only: [:index] diff --git a/spec/controllers/api/internal/carts_controller_spec.rb b/spec/controllers/checkout/carts_controller_spec.rb similarity index 99% rename from spec/controllers/api/internal/carts_controller_spec.rb rename to spec/controllers/checkout/carts_controller_spec.rb index c1b50608cb..990669f745 100644 --- a/spec/controllers/api/internal/carts_controller_spec.rb +++ b/spec/controllers/checkout/carts_controller_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -describe Api::Internal::CartsController do +describe Checkout::CartsController do let!(:seller) { create(:named_seller) } describe "PUT update" do diff --git a/spec/controllers/checkout_controller_spec.rb b/spec/controllers/checkout_controller_spec.rb index 7e5226812c..515c7f73e2 100644 --- a/spec/controllers/checkout_controller_spec.rb +++ b/spec/controllers/checkout_controller_spec.rb @@ -1,25 +1,31 @@ # frozen_string_literal: true require "spec_helper" -require "shared_examples/sellers_base_controller_concern" -require "shared_examples/authorize_called" - -describe CheckoutController do - render_views +require "inertia_rails/rspec" +describe CheckoutController, type: :controller, inertia: true do describe "GET index" do - it "returns HTTP success and assigns correct instance variables and force enables analytics" do + it "renders Inertia Checkout/Index component with correct props" do get :index - expect(assigns[:hide_layouts]).to eq(true) expect(response).to be_successful - - html = Nokogiri::HTML.parse(response.body) - expect(html.xpath("//meta[@property='gr:google_analytics:enabled']/@content").text).to eq("true") - expect(html.xpath("//meta[@property='gr:fb_pixel:enabled']/@content").text).to eq("true") - expect(html.xpath("//meta[@property='gr:logged_in_user:id']/@content").text).to eq("") - expect(html.xpath("//meta[@property='gr:page:type']/@content").text).to eq("") - expect(html.xpath("//meta[@property='gr:facebook_sdk:enabled']/@content").text).to eq("true") + expect(inertia.component).to eq("Checkout/Index") + + expect(inertia.props[:countries].size).to eq(Compliance::Countries.for_select.to_h.size) + expect(inertia.props[:countries][:US]).to eq("United States") + expect(inertia.props[:countries][:CA]).to eq("Canada") + expect(inertia.props[:us_states]).to eq(STATES) + expect(inertia.props[:ca_provinces]).to eq(Compliance::Countries.subdivisions_for_select(Compliance::Countries::CAN.alpha2).map(&:first)) + expect(inertia.props[:paypal_client_id]).to eq(PAYPAL_PARTNER_CLIENT_ID) + expect(inertia.props[:recaptcha_key]).to eq(GlobalConfig.get("RECAPTCHA_MONEY_SITE_KEY")) + expect(inertia.props[:discover_url]).to include(DISCOVER_DOMAIN) + expect(inertia.props[:gift]).to be_nil + expect(inertia.props[:clear_cart]).to eq(false) + expect(inertia.props[:saved_credit_card]).to be_nil + expect(inertia.props[:max_allowed_cart_products]).to eq(Cart::MAX_ALLOWED_CART_PRODUCTS) + expect(inertia.props[:tip_options]).to eq(TipOptionsService.get_tip_options) + expect(inertia.props[:default_tip_option]).to eq(TipOptionsService.get_default_tip_option) + expect(inertia.props[:add_products]).to eq([]) end describe "process_cart_id_param check" do @@ -32,12 +38,11 @@ sign_in user end - it "does not redirect when cart_id is blank" do + it "does not redirect when cart_id is blank and includes logged_in_user in props" do get :index expect(response).to be_successful - html = Nokogiri::HTML.parse(response.body) - expect(html.xpath("//meta[@property='gr:logged_in_user:id']/@content").text).to eq(user.external_id) + expect(inertia.props[:logged_in_user][:id]).to eq(user.external_id) end it "redirects to the same path removing the `cart_id` query param" do @@ -86,7 +91,7 @@ end end - context "when the cart matching the `cart_id` query param has the `browser_guid` same as the current `_gumroad_guid` cookie value" do + context "when the cart matching the `cart_id` query param has the `browser_guid` same as the current `_gumroad_guid` cookie value" do it "redirects to the same path without modifying the cart" do browser_guid = SecureRandom.uuid cookies[:_gumroad_guid] = browser_guid