diff --git a/app/controllers/discover/recommended_wishlists_controller.rb b/app/controllers/discover/recommended_wishlists_controller.rb deleted file mode 100644 index 0108d3bcdc..0000000000 --- a/app/controllers/discover/recommended_wishlists_controller.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -class Discover::RecommendedWishlistsController < ApplicationController - def index - wishlists = RecommendedWishlistsService.fetch( - limit: 4, - current_seller:, - curated_product_ids: (params[:curated_product_ids] || []).map { ObfuscateIds.decrypt(_1) }, - taxonomy_id: params[:taxonomy].present? ? Taxonomy.find_by_path(params[:taxonomy].split("/")).id : nil - ) - render json: WishlistPresenter.cards_props( - wishlists:, - pundit_user:, - layout: Product::Layout::DISCOVER, - recommended_by: RecommendationType::GUMROAD_DISCOVER_WISHLIST_RECOMMENDATION, - ) - end -end diff --git a/app/controllers/discover_controller.rb b/app/controllers/discover_controller.rb index d3c7159e7e..c3b5aaa663 100644 --- a/app/controllers/discover_controller.rb +++ b/app/controllers/discover_controller.rb @@ -7,14 +7,13 @@ class DiscoverController < ApplicationController include ActionView::Helpers::NumberHelper, RecommendationType, CreateDiscoverSearch, DiscoverCuratedProducts, SearchProducts, AffiliateCookie + layout "inertia", only: [:index] + before_action :set_affiliate_cookie, only: [:index] def index format_search_params! - @hide_layouts = true - @card_data_handling_mode = CardDataHandlingMode.get_card_data_handling_mode(logged_in_user) - if params[:sort].blank? && curated_products.present? params[:sort] = ProductSortKey::CURATED params[:curated_product_ids] = (curated_products[RECOMMENDED_PRODUCTS_COUNT..] || []).map { _1.product.id } @@ -49,17 +48,18 @@ def index prepare_discover_page - @react_discover_props = { + render inertia: "Discover/Index", props: { search_results: @search_results, currency_code: logged_in_user&.currency_type || "usd", taxonomies_for_nav:, - recommended_products: recommendations, + recommended_products: -> { recommendations }, + recommended_wishlists: -> { recommended_wishlists_data }, curated_product_ids: curated_products.map { _1.product.external_id }, search_offset: params[:from] || 0, - show_black_friday_hero: black_friday_feature_active?, + show_black_friday_hero: -> { black_friday_feature_active? }, is_black_friday_page: params[:offer_code] == SearchProducts::BLACK_FRIDAY_CODE, black_friday_offer_code: SearchProducts::BLACK_FRIDAY_CODE, - black_friday_stats: black_friday_feature_active? ? BlackFridayStatsService.fetch_stats : nil, + black_friday_stats: -> { black_friday_feature_active? ? BlackFridayStatsService.fetch_stats : nil }, } end @@ -134,9 +134,27 @@ def prepare_discover_page set_meta_tag(property: "og:site_name", content: "Gumroad") set_meta_tag(tag_name: "link", rel: "canonical", href: Discover::CanonicalUrlPresenter.canonical_url(params), head_key: "canonical") + # Page title for initial render and SEO crawlers. The frontend also generates titles via + # discoverTitleGenerator() when users navigate with filters, but we need this server-side + # for the first page load and search engines that don't execute JS. + title_parts = [] + if params[:query].present? + title_parts << "Search results for \"#{params[:query]}\"" + elsif params[:tags].present? && !params[:taxonomy].present? + presenter = Discover::TagPageMetaPresenter.new(params[:tags], @search_results[:total]) + title_parts << presenter.title + elsif params[:tags].present? + tags = params[:tags].is_a?(Array) ? params[:tags] : params[:tags].split(",") + title_parts << tags.map { |t| t.strip.gsub(/[-\s]+/, " ") }.join(", ") + end + if params[:taxonomy].present? + title_parts << params[:taxonomy].split("/").map { |slug| Discover::TaxonomyPresenter::TAXONOMY_LABELS[slug] || slug }.join(" » ") + end + title_parts << "Gumroad" + set_meta_tag(title: title_parts.join(" | ")) + if !params[:taxonomy].present? && !params[:query].present? && params[:tags].present? presenter = Discover::TagPageMetaPresenter.new(params[:tags], @search_results[:total]) - set_meta_tag(title: "#{presenter.title} | Gumroad") set_meta_tag(name: "description", content: presenter.meta_description) set_meta_tag(property: "og:description", content: presenter.meta_description) else @@ -149,4 +167,19 @@ def prepare_discover_page def black_friday_feature_active? Feature.active?(:offer_codes_search) || (params[:feature_key].present? && ActiveSupport::SecurityUtils.secure_compare(params[:feature_key].to_s, ENV["SECRET_FEATURE_KEY"].to_s)) end + + def recommended_wishlists_data + wishlists = RecommendedWishlistsService.fetch( + limit: 4, + current_seller:, + curated_product_ids: curated_products.map { _1.product.id }, + taxonomy_id: taxonomy&.id + ) + WishlistPresenter.cards_props( + wishlists:, + pundit_user:, + layout: Product::Layout::DISCOVER, + recommended_by: RecommendationType::GUMROAD_DISCOVER_WISHLIST_RECOMMENDATION, + ) + end end diff --git a/app/javascript/components/Discover/RecommendedWishlists.tsx b/app/javascript/components/Discover/RecommendedWishlists.tsx index d0ec3dc1f9..985268a9a2 100644 --- a/app/javascript/components/Discover/RecommendedWishlists.tsx +++ b/app/javascript/components/Discover/RecommendedWishlists.tsx @@ -1,35 +1,15 @@ import * as React from "react"; -import { fetchRecommendedWishlists } from "$app/data/wishlists"; -import { assertResponseError } from "$app/utils/request"; - -import { showAlert } from "$app/components/server-components/Alert"; -import { useRunOnce } from "$app/components/useRunOnce"; import { CardWishlist, CardGrid, Card, DummyCardGrid } from "$app/components/Wishlist/Card"; export const RecommendedWishlists = ({ title, - ...props + wishlists, }: { title: string; - curatedProductIds?: string[]; - taxonomy?: string | null; -}) => { - const [wishlists, setWishlists] = React.useState(null); - - useRunOnce(() => { - const loadWishlists = async () => { - try { - setWishlists(await fetchRecommendedWishlists(props)); - } catch (e) { - assertResponseError(e); - showAlert(e.message, "error"); - } - }; - void loadWishlists(); - }); - - return wishlists === null || wishlists.length > 0 ? ( + wishlists: CardWishlist[] | null | undefined; +}) => + wishlists === null || wishlists === undefined || wishlists.length > 0 ? (

{title}

@@ -46,4 +26,3 @@ export const RecommendedWishlists = ({ )}
) : null; -}; diff --git a/app/javascript/components/Shared/Footer.tsx b/app/javascript/components/Shared/Footer.tsx new file mode 100644 index 0000000000..3ad739318a --- /dev/null +++ b/app/javascript/components/Shared/Footer.tsx @@ -0,0 +1,115 @@ +import * as React from "react"; + +import logoG from "$assets/images/logo-g.svg"; + +export const Footer = () => ( +
+
+
+
+ Subscribe to get tips and tactics to grow the way you want. +
+
+ +
+ +
+ +
+
+
+ Gumroad icon +
Ⓒ Gumroad, Inc.
+
+
+
+
+
+ + Discover + + + Blog + + + Pricing + + + Features + + + About + + + Small Bets + +
+
+ + Help + + + Board meetings + + + Terms of Service + + + Privacy Policy + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+); diff --git a/app/javascript/components/server-components/Discover/index.tsx b/app/javascript/components/server-components/Discover/index.tsx deleted file mode 100644 index 0dbee9a077..0000000000 --- a/app/javascript/components/server-components/Discover/index.tsx +++ /dev/null @@ -1,507 +0,0 @@ -import { range } from "lodash-es"; -import * as React from "react"; -import { createCast, is } from "ts-safe-cast"; - -import { getRecommendedProducts } from "$app/data/discover"; -import { SearchResults, SearchRequest } from "$app/data/search"; -import { useScrollToElement } from "$app/hooks/useScrollToElement"; -import { CardProduct } from "$app/parsers/product"; -import { last } from "$app/utils/array"; -import { classNames } from "$app/utils/classNames"; -import { CurrencyCode, formatPriceCentsWithCurrencySymbol } from "$app/utils/currency"; -import { discoverTitleGenerator, Taxonomy } from "$app/utils/discover"; -import { asyncVoid } from "$app/utils/promise"; -import { assertResponseError } from "$app/utils/request"; -import { register } from "$app/utils/serverComponentUtil"; - -import { Layout } from "$app/components/Discover/Layout"; -import { RecommendedWishlists } from "$app/components/Discover/RecommendedWishlists"; -import { Icon } from "$app/components/Icons"; -import { HorizontalCard } from "$app/components/Product/Card"; -import { CardGrid, useSearchReducer } from "$app/components/Product/CardGrid"; -import { RatingStars } from "$app/components/RatingStars"; -import { CardContent } from "$app/components/ui/Card"; -import { Tabs, Tab } from "$app/components/ui/Tabs"; -import { useOnChange } from "$app/components/useOnChange"; -import { useOriginalLocation } from "$app/components/useOriginalLocation"; -import { useScrollableCarousel } from "$app/components/useScrollableCarousel"; - -import blackFridayImage from "$assets/images/illustrations/black_friday.svg"; -import saleImage from "$assets/images/illustrations/sale.svg"; - -type Props = { - currency_code: CurrencyCode; - search_results: SearchResults; - taxonomies_for_nav: Taxonomy[]; - recommended_products: CardProduct[]; - curated_product_ids: string[]; - show_black_friday_hero: boolean; - is_black_friday_page: boolean; - black_friday_offer_code: string; - black_friday_stats: { - active_deals_count: number; - revenue_cents: number; - average_discount_percentage: number; - } | null; -}; - -const sortTitles = { - curated: "Curated for you", - trending: "On the market", - hot_and_new: "Hot and new products", - best_sellers: "Best selling products", -}; - -const ProductsCarousel = ({ products, title }: { products: CardProduct[]; title: string }) => { - const [active, setActive] = React.useState(0); - const { itemsRef, handleScroll } = useScrollableCarousel(active, setActive); - const [dragStart, setDragStart] = React.useState(null); - - return ( -
-
-

{title}

-
- - {active + 1} / {products.length} - -
-
-
-
setDragStart(e.clientX)} - onMouseMove={(e) => { - if (dragStart == null || !itemsRef.current) return; - itemsRef.current.scrollLeft -= e.movementX; - }} - onClick={(e) => { - if (dragStart != null && Math.abs(e.clientX - dragStart) > 30) e.preventDefault(); - setDragStart(null); - }} - onMouseOut={() => setDragStart(null)} - > - {products.map((product, idx) => ( - // Only the first 3 cards are visible, so we can set eager loading for them - - ))} -
-
-
- ); -}; - -const BlackFridayBanner = ({ - stats, - currencyCode, -}: { - stats: { active_deals_count: number; revenue_cents: number; average_discount_percentage: number }; - currencyCode: CurrencyCode; -}) => ( -
- - BLACK FRIDAY IS LIVE - {stats.active_deals_count > 0 && ( - <> - - - {stats.active_deals_count.toLocaleString()}ACTIVE DEALS - - - )} - - CREATOR-MADE PRODUCTS - {stats.revenue_cents > 0 && ( - <> - - - - {formatPriceCentsWithCurrencySymbol(currencyCode, stats.revenue_cents, { symbolFormat: "short" })} - - IN SALES SO FAR - - - )} - - BIG SAVINGS - {stats.average_discount_percentage > 0 && ( - <> - - - {stats.average_discount_percentage}%AVERAGE DISCOUNT - - - )} -
-); - -// Featured products and search results overlap when there are no filters, so we skip over the featured products in the search request -// See DiscoverController::RECOMMENDED_PRODUCTS_COUNT -const recommendedProductsCount = 8; -const addInitialOffset = (params: SearchRequest) => - Object.entries(params).every(([key, value]) => !value || ["taxonomy", "curated_product_ids"].includes(key)) - ? { ...params, from: recommendedProductsCount + 1 } - : params; - -const BlackFridayButton = ({ - variant = "pink", - size = "default", - offerCode, - taxonomy = undefined, -}: { - variant?: "light" | "dark" | "pink"; - size?: "small" | "default"; - offerCode: string; - taxonomy: string | undefined; -}) => { - const variantClasses = { - light: "bg-black text-white", - dark: "bg-white text-black", - pink: "bg-pink text-black", - }; - - const sizeClasses = { - small: "h-12 px-3 text-base lg:h-12 lg:px-6 lg:text-base", - default: "h-14 px-8 text-xl lg:h-16 lg:px-10 lg:text-xl", - }; - - const buttonClasses = classNames( - "relative inline-flex rounded-sm no-underline items-center justify-center border border-black transition-all duration-150 group-hover:-translate-x-2 group-hover:-translate-y-2 z-3 w-full lg:w-auto", - variantClasses[variant], - sizeClasses[size], - ); - - const url = taxonomy - ? Routes.discover_taxonomy_path(taxonomy, { offer_code: offerCode }) - : Routes.discover_path({ offer_code: offerCode }); - - return ( -
-
-
- - Get Black Friday deals - -
- ); -}; - -const Discover = (props: Props) => { - const location = useOriginalLocation(); - - const defaultSortOrder = props.curated_product_ids.length > 0 ? "curated" : undefined; - const parseUrlParams = (href: string) => { - const url = new URL(href); - const parsedParams: SearchRequest = { - taxonomy: url.pathname === Routes.discover_path() ? undefined : url.pathname.replace("/", ""), - curated_product_ids: props.curated_product_ids.slice( - url.pathname === Routes.discover_path() ? recommendedProductsCount : 0, - ), - }; - - function parseParams(keys: T[], transform: (value: string) => SearchRequest[T]) { - for (const key of keys) { - const value = url.searchParams.get(key); - parsedParams[key] = value ? transform(value) : undefined; - } - } - - parseParams(["sort", "query", "offer_code"], (value) => value); - parseParams(["min_price", "max_price", "rating"], (value) => Number(value)); - parseParams(["filetypes", "tags"], (value) => value.split(",")); - if (!parsedParams.sort) parsedParams.sort = defaultSortOrder; - return parsedParams; - }; - const [state, dispatch] = useSearchReducer({ - params: addInitialOffset(parseUrlParams(location)), - results: props.search_results, - }); - - const isBlackFridayPage = state.params.offer_code === "BLACKFRIDAY2025"; - - const resultsRef = useScrollToElement(isBlackFridayPage && props.show_black_friday_hero, undefined, [state.params]); - - const fromUrl = React.useRef(false); - React.useEffect(() => { - if (!fromUrl.current) { - // don't pushState if we're already loading from history state - const url = new URL(window.location.href); - if (state.params.taxonomy) { - url.pathname = state.params.taxonomy; - } else if (url.pathname !== Routes.discover_path()) { - url.pathname = Routes.discover_path(); - } - const serializeParams = ( - keys: T[], - transform: (value: NonNullable) => string, - ) => { - for (const key of keys) { - const value = state.params[key]; - if (value && (!Array.isArray(value) || value.length)) url.searchParams.set(key, transform(value)); - else url.searchParams.delete(key); - } - }; - serializeParams(["sort", "query", "offer_code"], (value) => value); - serializeParams(["min_price", "max_price", "rating"], (value) => value.toString()); - serializeParams(["filetypes", "tags"], (value) => value.join(",")); - window.history.pushState(state.params, "", url); - } else fromUrl.current = false; - document.title = discoverTitleGenerator(state.params, props.taxonomies_for_nav); - }, [state.params]); - React.useEffect(() => { - const parseUrl = () => { - fromUrl.current = true; - const newParams = parseUrlParams(window.location.href); - dispatch({ - type: "set-params", - params: addInitialOffset(newParams), - }); - }; - window.addEventListener("popstate", parseUrl); - return () => window.removeEventListener("popstate", parseUrl); - }, [state.params.taxonomy]); - - const taxonomyPath = state.params.taxonomy; - - const updateParams = (newParams: Partial) => - dispatch({ type: "set-params", params: { ...state.params, from: undefined, ...newParams } }); - - const [recommendedProducts, setRecommendedProducts] = React.useState(props.recommended_products); - - const hasOfferCode = !!state.params.offer_code; - - useOnChange( - asyncVoid(async () => { - if (state.params.query || hasOfferCode) return; - setRecommendedProducts([]); - try { - setRecommendedProducts(await getRecommendedProducts({ taxonomy: state.params.taxonomy })); - } catch (e) { - assertResponseError(e); - } - }), - [state.params.taxonomy, hasOfferCode], - ); - - const isCuratedProducts = - recommendedProducts[0] && - new URL(recommendedProducts[0].url).searchParams.get("recommended_by") === "products_for_you"; - - const showRecommendedSections = recommendedProducts.length && !state.params.query && !hasOfferCode; - - return ( - { - // Read from URL to avoid stale state when user clicks Clear then immediately navigates - const currentUrl = new URL(window.location.href); - const currentOfferCode = currentUrl.searchParams.get("offer_code") || undefined; - dispatch({ - type: "set-params", - params: addInitialOffset({ - taxonomy: newTaxonomyPath, - sort: defaultSortOrder, - offer_code: newTaxonomyPath ? currentOfferCode : undefined, - }), - }); - }} - query={state.params.query} - setQuery={(query) => dispatch({ type: "set-params", params: { query, taxonomy: taxonomyPath } })} - > - {props.show_black_friday_hero ? ( -
-
- Sale -
- Black Friday - Sale -
- Sale -
- Snag creator-made deals
before they're gone. -
- {!isBlackFridayPage && ( -
- -
- )} -
-
-
- {props.black_friday_stats ? ( - <> - {/* Duplicate enough times to ensure seamless infinite scroll */} - {(() => { - const stats = props.black_friday_stats; - return Array.from({ length: 5 }, (_, i) => ( - - )); - })()} - - ) : null} -
-
-
- ) : null} -
- {showRecommendedSections ? ( - - ) : null} -
-
-

- {state.params.query || hasOfferCode - ? state.results?.products.length - ? `Showing 1-${state.results.products.length} of ${state.results.total} products` - : null - : sortTitles[is(state.params.sort) ? state.params.sort : "trending"]} -

- {state.params.query || hasOfferCode ? null : ( - - {props.curated_product_ids.length > 0 ? ( - - updateParams({ - sort: "curated", - curated_product_ids: props.curated_product_ids.slice(recommendedProductsCount), - }) - } - > - Curated - - ) : null} - updateParams({ sort: undefined })} - > - Trending - - {props.curated_product_ids.length === 0 ? ( - updateParams({ sort: "best_sellers" })} - > - Best Sellers - - ) : null} - updateParams({ sort: "hot_and_new" })} - > - Hot & New - - - )} -
- - -
- Rating -
- {range(4, 0).map((number) => ( - - ))} -
-
-
- {hasOfferCode ? ( - -
- - Offer code - -
- BLACKFRIDAY2025 - -
-
-
- ) : null} - - } - pagination="button" - /> -
- {showRecommendedSections ? ( - t.slug === last(taxonomyPath.split("/")))?.label}` - : "Wishlists you might like" - } - /> - ) : null} -
-
- ); -}; - -export default register({ component: Discover, propParser: createCast() }); diff --git a/app/javascript/data/wishlists.ts b/app/javascript/data/wishlists.ts index 2c60fc4b74..b753419661 100644 --- a/app/javascript/data/wishlists.ts +++ b/app/javascript/data/wishlists.ts @@ -162,18 +162,3 @@ export const fetchWishlists = async (ids: string[]) => { return cast(await response.json()); }; -export const fetchRecommendedWishlists = async ({ - curatedProductIds, - taxonomy, -}: { - curatedProductIds?: string[]; - taxonomy?: string | null; -}) => { - const response = await request({ - method: "GET", - url: Routes.discover_recommended_wishlists_path({ curated_product_ids: curatedProductIds, taxonomy }), - accept: "json", - }); - if (!response.ok) throw new ResponseError(); - return cast(await response.json()); -}; diff --git a/app/javascript/packs/discover.ts b/app/javascript/packs/discover.ts deleted file mode 100644 index 6c230347e3..0000000000 --- a/app/javascript/packs/discover.ts +++ /dev/null @@ -1,8 +0,0 @@ -import ReactOnRails from "react-on-rails"; - -import BasePage from "$app/utils/base_page"; - -import Discover from "$app/components/server-components/Discover"; - -BasePage.initialize(); -ReactOnRails.register({ Discover }); diff --git a/app/javascript/pages/Discover/Index.tsx b/app/javascript/pages/Discover/Index.tsx new file mode 100644 index 0000000000..b945ca4c6a --- /dev/null +++ b/app/javascript/pages/Discover/Index.tsx @@ -0,0 +1,501 @@ +import { Link, router, usePage } from "@inertiajs/react"; +import { range } from "lodash-es"; +import * as React from "react"; +import { is } from "ts-safe-cast"; + +import { getRecommendedProducts } from "$app/data/discover"; +import { SearchResults, SearchRequest } from "$app/data/search"; +import { useScrollToElement } from "$app/hooks/useScrollToElement"; +import { CardProduct } from "$app/parsers/product"; +import { CardWishlist } from "$app/components/Wishlist/Card"; +import { last } from "$app/utils/array"; +import { classNames } from "$app/utils/classNames"; +import { CurrencyCode, formatPriceCentsWithCurrencySymbol } from "$app/utils/currency"; +import { discoverTitleGenerator, Taxonomy } from "$app/utils/discover"; + +import { Footer } from "$app/components/Shared/Footer"; +import { Layout } from "$app/components/Discover/Layout"; +import { RecommendedWishlists } from "$app/components/Discover/RecommendedWishlists"; +import { Icon } from "$app/components/Icons"; +import { HorizontalCard } from "$app/components/Product/Card"; +import { CardGrid, useSearchReducer } from "$app/components/Product/CardGrid"; +import { RatingStars } from "$app/components/RatingStars"; +import { CardContent } from "$app/components/ui/Card"; +import { Tabs, Tab } from "$app/components/ui/Tabs"; +import { useScrollableCarousel } from "$app/components/useScrollableCarousel"; + +import blackFridayImage from "$assets/images/illustrations/black_friday.svg"; +import saleImage from "$assets/images/illustrations/sale.svg"; + +type DiscoverPageProps = { + currency_code: CurrencyCode; + search_results: SearchResults; + taxonomies_for_nav: Taxonomy[]; + recommended_products: CardProduct[]; + recommended_wishlists: CardWishlist[]; + curated_product_ids: string[]; + show_black_friday_hero: boolean; + is_black_friday_page: boolean; + black_friday_offer_code: string; + black_friday_stats: { + active_deals_count: number; + revenue_cents: number; + average_discount_percentage: number; + } | null; +}; + +const sortTitles = { + curated: "Curated for you", + trending: "On the market", + hot_and_new: "Hot and new products", + best_sellers: "Best selling products", +}; + +const ProductsCarousel = ({ products, title }: { products: CardProduct[]; title: string }) => { + const [active, setActive] = React.useState(0); + const { itemsRef, handleScroll } = useScrollableCarousel(active, setActive); + const [dragStart, setDragStart] = React.useState(null); + + return ( +
+
+

{title}

+
+ + {active + 1} / {products.length} + +
+
+
+
setDragStart(e.clientX)} + onMouseMove={(e) => { + if (dragStart == null || !itemsRef.current) return; + itemsRef.current.scrollLeft -= e.movementX; + }} + onClick={(e) => { + if (dragStart != null && Math.abs(e.clientX - dragStart) > 30) e.preventDefault(); + setDragStart(null); + }} + onMouseOut={() => setDragStart(null)} + > + {products.map((product, idx) => ( + + ))} +
+
+
+ ); +}; + +const BlackFridayBanner = ({ + stats, + currencyCode, +}: { + stats: { active_deals_count: number; revenue_cents: number; average_discount_percentage: number }; + currencyCode: CurrencyCode; +}) => ( +
+ + BLACK FRIDAY IS LIVE + {stats.active_deals_count > 0 && ( + <> + + + {stats.active_deals_count.toLocaleString()}ACTIVE DEALS + + + )} + + CREATOR-MADE PRODUCTS + {stats.revenue_cents > 0 && ( + <> + + + + {formatPriceCentsWithCurrencySymbol(currencyCode, stats.revenue_cents, { symbolFormat: "short" })} + + IN SALES SO FAR + + + )} + + BIG SAVINGS + {stats.average_discount_percentage > 0 && ( + <> + + + {stats.average_discount_percentage}%AVERAGE DISCOUNT + + + )} +
+); + +const recommendedProductsCount = 8; +const addInitialOffset = (params: SearchRequest) => + Object.entries(params).every(([key, value]) => !value || ["taxonomy", "curated_product_ids"].includes(key)) + ? { ...params, from: recommendedProductsCount + 1 } + : params; + +const BlackFridayButton = ({ + variant = "pink", + size = "default", + offerCode, + taxonomy = undefined, +}: { + variant?: "light" | "dark" | "pink"; + size?: "small" | "default"; + offerCode: string; + taxonomy: string | undefined; +}) => { + const variantClasses = { + light: "bg-black text-white", + dark: "bg-white text-black", + pink: "bg-pink text-black", + }; + + const sizeClasses = { + small: "h-12 px-3 text-base lg:h-12 lg:px-6 lg:text-base", + default: "h-14 px-8 text-xl lg:h-16 lg:px-10 lg:text-xl", + }; + + const buttonClasses = classNames( + "relative inline-flex rounded-sm no-underline items-center justify-center border border-black transition-all duration-150 group-hover:-translate-x-2 group-hover:-translate-y-2 z-3 w-full lg:w-auto", + variantClasses[variant], + sizeClasses[size], + ); + + const url = taxonomy + ? Routes.discover_taxonomy_path(taxonomy, { offer_code: offerCode }) + : Routes.discover_path({ offer_code: offerCode }); + + return ( +
+
+
+ + Get Black Friday deals + +
+ ); +}; + +const parseUrlParams = (href: string, curatedProductIds: string[], defaultSortOrder: string | undefined) => { + const url = new URL(href); + const parsedParams: SearchRequest = { + taxonomy: url.pathname === Routes.discover_path() ? undefined : url.pathname.replace("/", ""), + curated_product_ids: curatedProductIds.slice( + url.pathname === Routes.discover_path() ? recommendedProductsCount : 0, + ), + }; + + function parseParams(keys: T[], transform: (value: string) => SearchRequest[T]) { + for (const key of keys) { + const value = url.searchParams.get(key); + parsedParams[key] = value ? transform(value) : undefined; + } + } + + parseParams(["sort", "query", "offer_code"], (value) => value); + parseParams(["min_price", "max_price", "rating"], (value) => Number(value)); + parseParams(["filetypes", "tags"], (value) => value.split(",")); + if (!parsedParams.sort) parsedParams.sort = defaultSortOrder; + return parsedParams; +}; + +function DiscoverIndex() { + const props = usePage().props; + const defaultSortOrder = props.curated_product_ids.length > 0 ? "curated" : undefined; + + const [state, dispatch] = useSearchReducer({ + params: addInitialOffset(parseUrlParams(window.location.href, props.curated_product_ids, defaultSortOrder)), + results: props.search_results, + }); + + const isBlackFridayPage = state.params.offer_code === "BLACKFRIDAY2025"; + + const resultsRef = useScrollToElement(isBlackFridayPage && props.show_black_friday_hero, undefined, [state.params]); + + React.useEffect(() => { + const url = new URL(window.location.href); + if (state.params.taxonomy) { + url.pathname = state.params.taxonomy; + } else if (url.pathname !== Routes.discover_path()) { + url.pathname = Routes.discover_path(); + } + const serializeParams = ( + keys: T[], + transform: (value: NonNullable) => string, + ) => { + for (const key of keys) { + const value = state.params[key]; + if (value && (!Array.isArray(value) || value.length)) url.searchParams.set(key, transform(value)); + else url.searchParams.delete(key); + } + }; + serializeParams(["sort", "query", "offer_code"], (value) => value); + serializeParams(["min_price", "max_price", "rating"], (value) => value.toString()); + serializeParams(["filetypes", "tags"], (value) => value.join(",")); + + const urlString = url.pathname + url.search; + const currentUrlString = window.location.pathname + window.location.search; + if (urlString !== currentUrlString) { + router.visit(url.toString(), { + preserveState: true, + preserveScroll: true, + }); + } + document.title = discoverTitleGenerator(state.params, props.taxonomies_for_nav); + }, [state.params, props.taxonomies_for_nav, defaultSortOrder]); + + const taxonomyPath = state.params.taxonomy; + + const [recommendedProducts, setRecommendedProducts] = React.useState(props.recommended_products); + const initialTaxonomy = React.useRef( + window.location.pathname === Routes.discover_path() ? undefined : window.location.pathname.replace("/", ""), + ); + + React.useEffect(() => { + if (taxonomyPath !== initialTaxonomy.current) { + getRecommendedProducts({ taxonomy: taxonomyPath }) + .then(setRecommendedProducts) + .catch(() => setRecommendedProducts([])); + } else { + setRecommendedProducts(props.recommended_products); + } + }, [taxonomyPath, props.recommended_products]); + + const updateParams = (newParams: Partial) => + dispatch({ type: "set-params", params: { ...state.params, from: undefined, ...newParams } }); + + const hasOfferCode = !!state.params.offer_code; + + const isCuratedProducts = + recommendedProducts[0] && + new URL(recommendedProducts[0].url).searchParams.get("recommended_by") === "products_for_you"; + + const showRecommendedSections = recommendedProducts.length > 0 && !state.params.query && !hasOfferCode; + + const handleTaxonomyChange = (newTaxonomyPath: string | undefined) => { + const currentOfferCode = state.params.offer_code; + dispatch({ + type: "set-params", + params: addInitialOffset({ + taxonomy: newTaxonomyPath, + curated_product_ids: newTaxonomyPath ? [] : props.curated_product_ids.slice(recommendedProductsCount), + offer_code: newTaxonomyPath && currentOfferCode ? currentOfferCode : undefined, + }), + }); + }; + + return ( + <> + dispatch({ type: "set-params", params: { query, taxonomy: taxonomyPath } })} + > + {props.show_black_friday_hero ? ( +
+
+ Sale +
+ Black Friday + Sale +
+ Sale +
+ Snag creator-made deals
before they're gone. +
+ {!isBlackFridayPage && ( +
+ +
+ )} +
+
+
+ {props.black_friday_stats ? ( + <> + {(() => { + const stats = props.black_friday_stats; + return Array.from({ length: 5 }, (_, i) => ( + + )); + })()} + + ) : null} +
+
+
+ ) : null} +
+ {showRecommendedSections ? ( + + ) : null} +
+
+

+ {state.params.query || hasOfferCode + ? state.results?.products.length + ? `Showing 1-${state.results.products.length} of ${state.results.total} products` + : null + : sortTitles[is(state.params.sort) ? state.params.sort : "trending"]} +

+ {state.params.query || hasOfferCode ? null : ( + + {props.curated_product_ids.length > 0 ? ( + + updateParams({ + sort: "curated", + curated_product_ids: props.curated_product_ids.slice(recommendedProductsCount), + }) + } + > + Curated + + ) : null} + updateParams({ sort: undefined })} + > + Trending + + {props.curated_product_ids.length === 0 ? ( + updateParams({ sort: "best_sellers" })} + > + Best Sellers + + ) : null} + updateParams({ sort: "hot_and_new" })} + > + Hot & New + + + )} +
+ + +
+ Rating +
+ {range(4, 0).map((number) => ( + + ))} +
+
+
+ {hasOfferCode ? ( + +
+ + Offer code + +
+ BLACKFRIDAY2025 + +
+
+
+ ) : null} + + } + pagination="button" + /> +
+ {showRecommendedSections ? ( + t.slug === last(taxonomyPath.split("/")))?.label}` + : "Wishlists you might like" + } + /> + ) : null} +
+
+