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}
+
+
+
+
+
+ >
+ );
+};
+
+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}
-
-
-
-
-
- >
- );
-};
-
-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