diff --git a/app/controllers/links_controller.rb b/app/controllers/links_controller.rb index b71e7db651..79f8e72379 100644 --- a/app/controllers/links_controller.rb +++ b/app/controllers/links_controller.rb @@ -31,7 +31,7 @@ class LinksController < ApplicationController before_action :prepare_product_page, only: %i[show] before_action :ensure_domain_belongs_to_seller, only: [:show] before_action :fetch_product_and_enforce_ownership, only: %i[destroy] - before_action :fetch_product_and_enforce_access, only: %i[update publish unpublish release_preorder update_sections] + before_action :fetch_product_and_enforce_access, only: %i[release_preorder update_sections] layout "inertia", only: [:index, :new, :cart_items_count] @@ -105,9 +105,9 @@ def create create_user_event("add_product") if ai_generated - redirect_to edit_link_path(@product, ai_generated: true), status: :see_other + redirect_to edit_product_product_path(@product, ai_generated: true), status: :see_other else - redirect_to edit_link_path(@product), status: :see_other + redirect_to edit_product_product_path(@product), status: :see_other end end @@ -275,175 +275,6 @@ def track_user_action render json: { success: true } end - def edit - fetch_product_by_unique_permalink - authorize @product - - redirect_to edit_bundle_product_path(@product.external_id) if @product.is_bundle? - - set_meta_tag(title: @product.name) - - ai_generated = params[:ai_generated] == "true" - @presenter = ProductPresenter.new(product: @product, pundit_user:, ai_generated:) - end - - def update - authorize @product - begin - ActiveRecord::Base.transaction do - @product.assign_attributes(product_permitted_params.except( - :products, - :description, - :cancellation_discount, - :custom_button_text_option, - :custom_summary, - :custom_attributes, - :file_attributes, - :covers, - :refund_policy, - :product_refund_policy_enabled, - :seller_refund_policy_enabled, - :integrations, - :variants, - :tags, - :section_ids, - :availabilities, - :custom_domain, - :rich_content, - :files, - :public_files, - :shipping_destinations, - :call_limitation_info, - :installment_plan, - :community_chat_enabled, - :default_offer_code_id - )) - @product.description = SaveContentUpsellsService.new(seller: @product.user, content: product_permitted_params[:description], old_content: @product.description_was).from_html - @product.skus_enabled = false - @product.save_custom_button_text_option(product_permitted_params[:custom_button_text_option]) unless product_permitted_params[:custom_button_text_option].nil? - @product.save_custom_summary(product_permitted_params[:custom_summary]) unless product_permitted_params[:custom_summary].nil? - @product.save_custom_attributes((product_permitted_params[:custom_attributes] || []).filter { _1[:name].present? || _1[:description].present? }) - @product.save_tags!(product_permitted_params[:tags] || []) - @product.reorder_previews((product_permitted_params[:covers] || []).map.with_index.to_h) - if !current_seller.account_level_refund_policy_enabled? - @product.product_refund_policy_enabled = product_permitted_params[:product_refund_policy_enabled] - if product_permitted_params[:refund_policy].present? && product_permitted_params[:product_refund_policy_enabled] - @product.find_or_initialize_product_refund_policy.update!(product_permitted_params[:refund_policy]) - end - end - @product.show_in_sections!(product_permitted_params[:section_ids] || []) - @product.save_shipping_destinations!(product_permitted_params[:shipping_destinations] || []) if @product.is_physical - - if Feature.active?(:cancellation_discounts, @product.user) && (product_permitted_params[:cancellation_discount].present? || @product.cancellation_discount_offer_code.present?) - begin - Product::SaveCancellationDiscountService.new(@product, product_permitted_params[:cancellation_discount]).perform - rescue ActiveRecord::RecordInvalid => e - return render json: { error_message: e.record.errors.full_messages.first }, status: :unprocessable_entity - end - end - - if @product.native_type === Link::NATIVE_TYPE_COFFEE - @product.suggested_price_cents = product_permitted_params[:variants].map { _1[:price_difference_cents] }.max - end - - # TODO clean this up - rich_content = product_permitted_params[:rich_content] || [] - rich_content_params = [*rich_content] - product_permitted_params[:variants].each { rich_content_params.push(*_1[:rich_content]) } if product_permitted_params[:variants].present? - rich_content_params = rich_content_params.flat_map { _1[:description] = _1.dig(:description, :content) } - rich_contents_to_keep = [] - SaveFilesService.perform(@product, product_permitted_params, rich_content_params) - existing_rich_contents = @product.alive_rich_contents.to_a - rich_content.each.with_index do |product_rich_content, index| - rich_content = existing_rich_contents.find { |c| c.external_id === product_rich_content[:id] } || @product.alive_rich_contents.build - product_rich_content[:description] = SaveContentUpsellsService.new(seller: @product.user, content: product_rich_content[:description], old_content: rich_content.description || []).from_rich_content - rich_content.update!(title: product_rich_content[:title].presence, description: product_rich_content[:description].presence || [], position: index) - rich_contents_to_keep << rich_content - end - (existing_rich_contents - rich_contents_to_keep).each(&:mark_deleted!) - - Product::SaveIntegrationsService.perform(@product, product_permitted_params[:integrations]) - update_variants - update_removed_file_attributes - update_custom_domain - update_availabilities - update_call_limitation_info - update_installment_plan - update_default_offer_code - - Product::SavePostPurchaseCustomFieldsService.new(@product).perform - - @product.is_licensed = @product.has_embedded_license_key? - unless @product.is_licensed - @product.is_multiseat_license = false - end - @product.description = SavePublicFilesService.new(resource: @product, files_params: product_permitted_params[:public_files], content: @product.description).process - @product.save! - toggle_community_chat!(product_permitted_params[:community_chat_enabled]) - @product.generate_product_files_archives! - end - rescue ActiveRecord::RecordNotSaved, ActiveRecord::RecordInvalid, Link::LinkInvalid => e - if @product.errors.details[:custom_fields].present? - error_message = "You must add titles to all of your inputs" - else - error_message = @product.errors.full_messages.first || e.message - end - return render json: { error_message: }, status: :unprocessable_entity - end - invalid_currency_offer_codes = @product.product_and_universal_offer_codes.reject do |offer_code| - offer_code.is_currency_valid?(@product) - end.map(&:code) - invalid_amount_offer_codes = @product.product_and_universal_offer_codes.reject { _1.is_amount_valid?(@product) }.map(&:code) - - all_invalid_offer_codes = (invalid_currency_offer_codes + invalid_amount_offer_codes).uniq - - if all_invalid_offer_codes.any? - # Determine the main issue type for the message - has_currency_issues = invalid_currency_offer_codes.any? - has_amount_issues = invalid_amount_offer_codes.any? - - if has_currency_issues && has_amount_issues - issue_description = "#{"has".pluralize(all_invalid_offer_codes.count)} currency mismatches or would discount this product below #{@product.min_price_formatted}" - elsif has_currency_issues - issue_description = "#{"has".pluralize(all_invalid_offer_codes.count)} currency #{"mismatch".pluralize(all_invalid_offer_codes.count)} with this product" - else - issue_description = "#{all_invalid_offer_codes.count > 1 ? "discount" : "discounts"} this product below #{@product.min_price_formatted}, but not to #{MoneyFormatter.format(0, @product.price_currency_type.to_sym, no_cents_if_whole: true, symbol: true)}" - end - - return render json: { - warning_message: "The following offer #{"code".pluralize(all_invalid_offer_codes.count)} #{issue_description}: #{all_invalid_offer_codes.join(", ")}. Please update #{all_invalid_offer_codes.length > 1 ? "them or they" : "it or it"} will not work at checkout." - } - end - - head :no_content - end - - def unpublish - authorize @product - - @product.unpublish! - render json: { success: true } - end - - def publish - authorize @product - - if @product.user.email.blank? - return render json: { success: false, error_message: "To publish a product, we need you to have an email. Set an email to continue." } - end - - begin - @product.publish! - rescue Link::LinkInvalid, ActiveRecord::RecordInvalid - return render json: { success: false, error_message: @product.errors.full_messages[0] } - rescue => e - Bugsnag.notify(e) - return render json: { success: false, error_message: "Something broke. We're looking into what happened. Sorry about this!" } - end - - render json: { success: true } - end - def destroy authorize @product @@ -495,6 +326,20 @@ def send_sample_price_change_email end private + def product_edit_redirect_url + referer = request.referer.to_s + permalink = @product.unique_permalink + if referer.include?("/share/edit") || referer.include?("/edit/share") + @product.native_type == Link::NATIVE_TYPE_COFFEE ? edit_product_product_path(permalink) : edit_product_content_path(permalink) + elsif referer.include?("/content/edit") || referer.include?("/edit/content") + edit_product_content_path(permalink) + elsif referer.include?("/receipt/edit") || referer.include?("/edit/receipt") + edit_product_receipt_path(permalink) + else + edit_product_product_path(permalink) + end + end + def fetch_product_for_show fetch_product_by_custom_domain || fetch_product_by_general_permalink end @@ -621,115 +466,6 @@ def extract_sort_params(prefix, permitted) { key:, direction: direction == "desc" ? "desc" : "asc" } end - def update_removed_file_attributes - current = @product.file_info_for_product_page.keys.map(&:to_s) - updated = (product_permitted_params[:file_attributes] || []).map { _1[:name] } - @product.add_removed_file_info_attributes(current - updated) - end - - def update_variants - variant_category = @product.variant_categories_alive.first - variants = product_permitted_params[:variants] || [] - if variants.any? || @product.is_tiered_membership? - variant_category_params = variant_category.present? ? - { - id: variant_category.external_id, - name: variant_category.title, - } : - { name: @product.is_tiered_membership? ? "Tier" : "Version" } - Product::VariantsUpdaterService.new( - product: @product, - variants_params: [ - { - **variant_category_params, - options: variants, - } - ], - ).perform - elsif variant_category.present? - Product::VariantsUpdaterService.new( - product: @product, - variants_params: [ - { - id: variant_category.external_id, - options: nil, - } - ]).perform - end - end - - def update_custom_domain - if product_permitted_params[:custom_domain].present? - custom_domain = @product.custom_domain || @product.build_custom_domain - custom_domain.domain = product_permitted_params[:custom_domain] - custom_domain.verify(allow_incrementing_failed_verification_attempts_count: false) - custom_domain.save! - elsif product_permitted_params[:custom_domain] == "" && @product.custom_domain.present? - @product.custom_domain.mark_deleted! - end - end - - def update_availabilities - return unless @product.native_type == Link::NATIVE_TYPE_CALL - - existing_availabilities = @product.call_availabilities - availabilities_to_keep = [] - (product_permitted_params[:availabilities] || []).each do |availability_params| - availability = existing_availabilities.find { _1.id == availability_params[:id] } || @product.call_availabilities.build - availability.update!(availability_params.except(:id)) - availabilities_to_keep << availability - end - (existing_availabilities - availabilities_to_keep).each(&:destroy!) - end - - def update_call_limitation_info - return unless @product.native_type == Link::NATIVE_TYPE_CALL - - @product.call_limitation_info.update!(product_permitted_params[:call_limitation_info]) - end - - def update_installment_plan - return unless @product.eligible_for_installment_plans? - - if @product.installment_plan && product_permitted_params[:installment_plan].present? - @product.installment_plan.assign_attributes(product_permitted_params[:installment_plan]) - return unless @product.installment_plan.changed? - end - - @product.installment_plan&.destroy_if_no_payment_options! - @product.reset_installment_plan - - if product_permitted_params[:installment_plan].present? - @product.create_installment_plan!(product_permitted_params[:installment_plan]) - end - end - - def update_default_offer_code - default_offer_code_id = product_permitted_params[:default_offer_code_id] - - return @product.default_offer_code = nil if default_offer_code_id.blank? - - offer_code = @product.user.offer_codes.alive.find_by_external_id!(default_offer_code_id) - - raise Link::LinkInvalid, "Offer code cannot be expired" if offer_code.inactive? - raise Link::LinkInvalid, "Offer code must be associated with this product or be universal" unless valid_for_product?(offer_code) - - @product.default_offer_code = offer_code - rescue ActiveRecord::RecordNotFound - raise Link::LinkInvalid, "Invalid offer code" - end - - def valid_for_product?(offer_code) - offer_code.universal? || @product.offer_codes.where(id: offer_code.id).exists? - end - - def toggle_community_chat!(enabled) - return unless Feature.active?(:communities, current_seller) - return if [Link::NATIVE_TYPE_COFFEE, Link::NATIVE_TYPE_BUNDLE].include?(@product.native_type) - - @product.toggle_community_chat!(enabled) - end - def generate_product_details_using_ai if Rails.env.test? generate_product_cover_and_thumbnail_using_ai diff --git a/app/controllers/products/base_controller.rb b/app/controllers/products/base_controller.rb new file mode 100644 index 0000000000..56f676b05e --- /dev/null +++ b/app/controllers/products/base_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class Products::BaseController < Sellers::BaseController + before_action :fetch_product + before_action :authorize_product + before_action :set_product_edit_title + + layout "inertia" + + protected + def check_offer_codes_validity + invalid_currency_offer_codes = @product.product_and_universal_offer_codes.reject do |offer_code| + offer_code.is_currency_valid?(@product) + end.map(&:code) + invalid_amount_offer_codes = @product.product_and_universal_offer_codes.reject { _1.is_amount_valid?(@product) }.map(&:code) + + all_invalid_offer_codes = (invalid_currency_offer_codes + invalid_amount_offer_codes).uniq + + if all_invalid_offer_codes.any? + has_currency_issues = invalid_currency_offer_codes.any? + has_amount_issues = invalid_amount_offer_codes.any? + + if has_currency_issues && has_amount_issues + issue_description = "#{"has".pluralize(all_invalid_offer_codes.count)} currency mismatches or would discount this product below #{@product.min_price_formatted}" + elsif has_currency_issues + issue_description = "#{"has".pluralize(all_invalid_offer_codes.count)} currency #{"mismatch".pluralize(all_invalid_offer_codes.count)} with this product" + else + issue_description = "#{all_invalid_offer_codes.count > 1 ? "discount" : "discounts"} this product below #{@product.min_price_formatted}, but not to #{MoneyFormatter.format(0, @product.price_currency_type.to_sym, no_cents_if_whole: true, symbol: true)}" + end + + flash[:warning] = "The following offer #{"code".pluralize(all_invalid_offer_codes.count)} #{issue_description}: #{all_invalid_offer_codes.join(", ")}. Please update #{all_invalid_offer_codes.length > 1 ? "them or they" : "it or it"} will not work at checkout." + end + end + + private + def set_product_edit_title + set_meta_tag(title: @product.name) + end + + def fetch_product + product_id = params[:product_id] || params[:id] + @product = Link.fetch_leniently(product_id, user: current_seller) || Link.fetch_leniently(product_id) + raise(ActiveRecord::RecordNotFound) if @product.nil? || @product.deleted_at.present? + end + + def authorize_product + authorize @product + end +end diff --git a/app/controllers/products/content_controller.rb b/app/controllers/products/content_controller.rb new file mode 100644 index 0000000000..d56f9cd115 --- /dev/null +++ b/app/controllers/products/content_controller.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +class Products::ContentController < Products::BaseController + def edit + render inertia: "Products/Content/Edit", props: Products::Edit::ContentTabPresenter.new(product: @product, pundit_user:).props + end + + def update + authorize @product + + should_publish = params[:publish].present? && !@product.published? + should_unpublish = params[:unpublish].present? && @product.published? + + if should_unpublish + @product.unpublish! + check_offer_codes_validity + return redirect_back fallback_location: edit_product_content_path(@product.unique_permalink), notice: "Unpublished!", status: :see_other + end + + # Publish-only request (e.g. after "Save" then "Publish and continue" from frontend) + if should_publish && params[:product].blank? + @product.publish! + check_offer_codes_validity + return redirect_to edit_product_share_path(@product.unique_permalink), notice: "Published!", status: :see_other + end + + ActiveRecord::Base.transaction do + update_content_attributes + @product.publish! if should_publish + end + + check_offer_codes_validity + + if should_publish + redirect_to edit_product_share_path(@product.unique_permalink), notice: "Published!", status: :see_other + elsif params[:redirect_to].present? + redirect_to params[:redirect_to], notice: "Changes saved!", status: :see_other + else + redirect_back fallback_location: edit_product_content_path(@product.unique_permalink), notice: "Changes saved!", status: :see_other + end + rescue ActiveRecord::RecordNotSaved, ActiveRecord::RecordInvalid, Link::LinkInvalid => e + error_message = @product.errors.full_messages.first || e.message + redirect_to edit_product_content_path(@product.unique_permalink), alert: error_message, status: :see_other + end + + private + def update_content_attributes + @product.assign_attributes(product_permitted_params.except(:files, :variants, :custom_domain, :rich_content)) + SaveFilesService.perform(@product, product_permitted_params, rich_content_params) + update_rich_content + @product.save! + @product.generate_product_files_archives! + end + + def update_rich_content + rich_content = product_permitted_params[:rich_content] || [] + existing_rich_contents = @product.alive_rich_contents.to_a + rich_contents_to_keep = [] + + rich_content.each.with_index do |product_rich_content, index| + rc = existing_rich_contents.find { |c| c.external_id === product_rich_content[:id] } || @product.alive_rich_contents.build + description = product_rich_content[:description].to_h[:content] + product_rich_content[:description] = SaveContentUpsellsService.new( + seller: @product.user, + content: description, + old_content: rc.description || [] + ).from_rich_content + rc.update!(title: product_rich_content[:title].presence, description: product_rich_content[:description].presence || [], position: index) + rich_contents_to_keep << rc + end + + (existing_rich_contents - rich_contents_to_keep).each(&:mark_deleted!) + end + + def rich_content_params + rich_content = product_permitted_params[:rich_content] || [] + rich_content_params = [*rich_content] + product_permitted_params[:variants]&.each { rich_content_params.push(*_1[:rich_content]) } + rich_content_params.flat_map { _1[:description] = _1.dig(:description, :content) } + end + + def product_permitted_params + params.require(:product).permit(policy(@product).content_tab_permitted_attributes) + end +end diff --git a/app/controllers/products/product_controller.rb b/app/controllers/products/product_controller.rb new file mode 100644 index 0000000000..4e738bc6f2 --- /dev/null +++ b/app/controllers/products/product_controller.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +class Products::ProductController < Products::BaseController + def edit + return redirect_to bundle_path(@product.external_id) if @product.is_bundle? + + render inertia: "Products/Product/Edit", props: Products::Edit::ProductTabPresenter.new(product: @product, pundit_user:).props + end + + def update + authorize @product + + should_publish = params[:publish].present? && !@product.published? + should_unpublish = params[:unpublish].present? && @product.published? + was_published = @product.published? + + if should_unpublish + @product.unpublish! + check_offer_codes_validity + return redirect_back fallback_location: edit_product_product_path(@product.unique_permalink), notice: "Unpublished!", status: :see_other + end + + # Publish-only request (e.g. after "Save" then "Publish and continue" from frontend) + if should_publish && params[:product].blank? + @product.publish! + check_offer_codes_validity + return redirect_to edit_product_share_path(@product.unique_permalink), notice: "Published!", status: :see_other + end + + ActiveRecord::Base.transaction do + update_product_attributes + @product.publish! if should_publish + end + + check_offer_codes_validity + + if should_publish + redirect_to edit_product_share_path(@product.unique_permalink), notice: "Published!", status: :see_other + elsif params[:redirect_to].present? + redirect_to params[:redirect_to], notice: "Changes saved!", status: :see_other + elsif was_published + redirect_back fallback_location: edit_product_product_path(@product.unique_permalink), notice: "Changes saved!", status: :see_other + else + redirect_to edit_product_content_path(@product.unique_permalink), notice: "Changes saved!", status: :see_other + end + rescue ActiveRecord::RecordNotSaved, ActiveRecord::RecordInvalid, Link::LinkInvalid => e + error_message = if @product.errors.details[:custom_fields].present? + "You must add titles to all of your inputs" + else + @product.errors.full_messages.first || e.message + end + redirect_to edit_product_product_path(@product.unique_permalink), alert: error_message, status: :see_other + end + + private + + def update_product_attributes + @product.assign_attributes(product_permitted_params.except( + :description, + :cancellation_discount, + :custom_button_text_option, + :custom_summary, + :custom_attributes, + :covers, + :integrations, + :variants, + :section_ids, + :availabilities, + :call_limitation_info, + :installment_plan, + :default_offer_code_id, + :public_files, + :shipping_destinations, + :community_chat_enabled, + :custom_domain, + :file_attributes, + :refund_policy + )) + + if @product.native_type === ::Link::NATIVE_TYPE_COFFEE && product_permitted_params[:variants].present? + @product.suggested_price_cents = product_permitted_params[:variants].map { _1[:price_difference_cents] }.max + end + + @product.description = SaveContentUpsellsService.new( + seller: @product.user, + content: product_permitted_params[:description], + old_content: @product.description_was + ).from_html if product_permitted_params[:description].present? + + @product.save_custom_button_text_option(product_permitted_params[:custom_button_text_option]) unless product_permitted_params[:custom_button_text_option].nil? + @product.save_custom_summary(product_permitted_params[:custom_summary]) unless product_permitted_params[:custom_summary].nil? + @product.save_custom_attributes((product_permitted_params[:custom_attributes] || []).filter { _1[:name].present? || _1[:description].present? }) + @product.reorder_previews((product_permitted_params[:covers] || []).map.with_index.to_h) + @product.show_in_sections!(product_permitted_params[:section_ids] || []) + @product.save_shipping_destinations!(product_permitted_params[:shipping_destinations] || []) if @product.is_physical + + if Feature.active?(:cancellation_discounts, @product.user) && (product_permitted_params[:cancellation_discount].present? || @product.cancellation_discount_offer_code.present?) + Product::SaveCancellationDiscountService.new(@product, product_permitted_params[:cancellation_discount]).perform + end + + Product::SaveIntegrationsService.perform(@product, product_permitted_params[:integrations]) + update_variants + update_availabilities + update_call_limitation_info + update_installment_plan + update_default_offer_code + + Product::SavePostPurchaseCustomFieldsService.new(@product).perform + + @product.description = SavePublicFilesService.new( + resource: @product, + files_params: product_permitted_params[:public_files], + content: @product.description + ).process if product_permitted_params[:public_files].present? + + toggle_community_chat!(product_permitted_params[:community_chat_enabled]) + @product.save! + end + + def update_variants + variant_category = @product.variant_categories_alive.first + variants = product_permitted_params[:variants] || [] + if variants.any? || @product.is_tiered_membership? + variant_category_params = variant_category.present? ? + { id: variant_category.external_id, name: variant_category.title } : + { name: @product.is_tiered_membership? ? "Tier" : "Version" } + + Product::VariantsUpdaterService.new( + product: @product, + variants_params: [{ **variant_category_params, options: variants }], + ).perform + elsif variant_category.present? + Product::VariantsUpdaterService.new( + product: @product, + variants_params: [{ id: variant_category.external_id, options: nil }] + ).perform + end + end + + def update_availabilities + return unless @product.native_type == ::Link::NATIVE_TYPE_CALL + existing_availabilities = @product.call_availabilities + availabilities_to_keep = [] + (product_permitted_params[:availabilities] || []).each do |availability_params| + availability = existing_availabilities.find { _1.id == availability_params[:id] } || @product.call_availabilities.build + availability.update!(availability_params.except(:id)) + availabilities_to_keep << availability + end + (existing_availabilities - availabilities_to_keep).each(&:destroy!) + end + + def update_call_limitation_info + return unless @product.native_type == ::Link::NATIVE_TYPE_CALL + @product.call_limitation_info.update!(product_permitted_params[:call_limitation_info]) if product_permitted_params[:call_limitation_info].present? + end + + def update_installment_plan + return unless @product.eligible_for_installment_plans? + if @product.installment_plan && product_permitted_params[:installment_plan].present? + @product.installment_plan.assign_attributes(product_permitted_params[:installment_plan]) + return unless @product.installment_plan.changed? + end + + @product.installment_plan&.destroy_if_no_payment_options! + @product.reset_installment_plan + if product_permitted_params[:installment_plan].present? + @product.create_installment_plan!(product_permitted_params[:installment_plan]) + end + end + + def update_default_offer_code + default_offer_code_id = product_permitted_params[:default_offer_code_id] + return @product.default_offer_code = nil if default_offer_code_id.blank? + + offer_code = @product.user.offer_codes.alive.find_by_external_id!(default_offer_code_id) + raise ::Link::LinkInvalid, "Offer code cannot be expired" if offer_code.inactive? + raise ::Link::LinkInvalid, "Offer code must be associated with this product or be universal" unless valid_for_product?(offer_code) + @product.default_offer_code = offer_code + rescue ActiveRecord::RecordNotFound + raise ::Link::LinkInvalid, "Invalid offer code" + end + + def valid_for_product?(offer_code) + offer_code.universal? || @product.offer_codes.where(id: offer_code.id).exists? + end + + def toggle_community_chat!(enabled) + return if enabled.nil? + return unless Feature.active?(:communities, current_seller) + return if [::Link::NATIVE_TYPE_COFFEE, ::Link::NATIVE_TYPE_BUNDLE].include?(@product.native_type) + @product.toggle_community_chat!(enabled) + end + + def product_permitted_params + params.require(:product).permit(policy(@product).product_tab_permitted_attributes) + end +end diff --git a/app/controllers/products/receipt_controller.rb b/app/controllers/products/receipt_controller.rb new file mode 100644 index 0000000000..9cb76945bc --- /dev/null +++ b/app/controllers/products/receipt_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class Products::ReceiptController < Products::BaseController + def edit + render inertia: "Products/Receipt/Edit", props: Products::Edit::ReceiptTabPresenter.new(product: @product, pundit_user:).props + end + + def update + should_publish = params[:publish].present? && !@product.published? + should_unpublish = params[:unpublish].present? && @product.published? + + if should_unpublish + @product.unpublish! + check_offer_codes_validity + return redirect_back fallback_location: edit_product_receipt_path(@product.unique_permalink), notice: "Unpublished!", status: :see_other + end + + begin + ActiveRecord::Base.transaction do + update_receipt_attributes + @product.publish! if should_publish + end + rescue ActiveRecord::RecordNotSaved, ActiveRecord::RecordInvalid, Link::LinkInvalid => e + error_message = @product.errors.full_messages.first || e.message + return redirect_to edit_product_receipt_path(@product.unique_permalink), alert: error_message, status: :see_other + end + + check_offer_codes_validity + + if should_publish + redirect_to edit_product_share_path(@product.unique_permalink), notice: "Published!", status: :see_other + else + redirect_to edit_product_receipt_path(@product.unique_permalink), notice: "Changes saved!", status: :see_other + end + end + + private + + def update_receipt_attributes + @product.assign_attributes(product_permitted_params.except(:custom_domain)) + @product.save! + end + + def product_permitted_params + params.require(:product).permit(policy(@product).receipt_tab_permitted_attributes) + end +end diff --git a/app/controllers/products/share_controller.rb b/app/controllers/products/share_controller.rb new file mode 100644 index 0000000000..42e1f59711 --- /dev/null +++ b/app/controllers/products/share_controller.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Products::ShareController < Products::BaseController + before_action :ensure_published_for_share, only: [:edit] + + def edit + render inertia: "Products/Share/Edit", props: Products::Edit::ShareTabPresenter.new(product: @product, pundit_user:).props + end + + def update + authorize @product + + ActiveRecord::Base.transaction do + update_share_attributes + @product.unpublish! if params[:unpublish].present? && @product.published? + end + + check_offer_codes_validity + + if params[:unpublish].present? + redirect_to edit_product_content_path(@product.unique_permalink), notice: "Unpublished!", status: :see_other + else + redirect_back fallback_location: edit_product_share_path(@product.unique_permalink), notice: "Changes saved!", status: :see_other + end + rescue ActiveRecord::RecordNotSaved, ActiveRecord::RecordInvalid, Link::LinkInvalid => e + error_message = @product.errors.full_messages.first || e.message + redirect_to edit_product_share_path(@product.unique_permalink), alert: error_message, status: :see_other + end + + private + + def ensure_published_for_share + return if !@product.draft && @product.alive? + + redirect_path = @product.native_type == Link::NATIVE_TYPE_COFFEE ? edit_product_product_path(@product.unique_permalink) : edit_product_content_path(@product.unique_permalink) + redirect_to redirect_path, alert: "Not yet! You've got to publish your awesome product before you can share it with your audience and the world." + end + + def update_share_attributes + @product.assign_attributes(product_permitted_params.except(:tags)) + @product.save_tags!(product_permitted_params[:tags] || []) + update_custom_domain if product_permitted_params.key?(:custom_domain) + @product.save! + end + + def update_custom_domain + if product_permitted_params[:custom_domain].present? + custom_domain = @product.custom_domain || @product.build_custom_domain + custom_domain.domain = product_permitted_params[:custom_domain] + custom_domain.verify(allow_incrementing_failed_verification_attempts_count: false) + custom_domain.save! + elsif product_permitted_params[:custom_domain] == "" && @product.custom_domain.present? + @product.custom_domain.mark_deleted! + end + end + + def product_permitted_params + params.fetch(:product, {}).permit(policy(@product).share_tab_permitted_attributes) + end +end diff --git a/app/javascript/components/CustomDomain.tsx b/app/javascript/components/CustomDomain.tsx index 0c4884fefc..caaa00cc8e 100644 --- a/app/javascript/components/CustomDomain.tsx +++ b/app/javascript/components/CustomDomain.tsx @@ -86,13 +86,13 @@ const CustomDomain = ({ id={uid} placeholder="yourdomain.com" type="text" - value={customDomain} + value={customDomain ?? ""} onChange={(e) => { setCustomDomain(e.target.value); setVerificationInfo({ buttonState: "initial", state: "initial", message: "" }); }} /> - {customDomain.trim() !== "" ? ( + {(customDomain ?? "").trim() !== "" ? ( + + + } + > + If you proceed, the content from all other versions of this product will be removed and replaced with the + content of "{titleWithFallback(selectedVariant?.name)}". + This action is irreversible. + + + )} + /> + + ); +}; diff --git a/app/javascript/components/ProductEdit/Layout.tsx b/app/javascript/components/ProductEdit/Layout.tsx index 85bb4afc78..1fa3124c20 100644 --- a/app/javascript/components/ProductEdit/Layout.tsx +++ b/app/javascript/components/ProductEdit/Layout.tsx @@ -1,11 +1,8 @@ +import { Link, router, usePage } from "@inertiajs/react"; import cx from "classnames"; import * as React from "react"; -import { Link, useMatches, useNavigate } from "react-router-dom"; -import { saveProduct } from "$app/data/product_edit"; -import { setProductPublished } from "$app/data/publish_product"; import { classNames } from "$app/utils/classNames"; -import { assertResponseError } from "$app/utils/request"; import { Button, NavigationButton } from "$app/components/Button"; import { CopyToClipboard } from "$app/components/CopyToClipboard"; @@ -137,43 +134,65 @@ export const Layout = ({ showBorder?: boolean; showNavigationButton?: boolean; }) => { - const { id, product, updateProduct, uniquePermalink, saving, save, currencyType } = useProductEditContext(); - const rootPath = `/products/${uniquePermalink}/edit`; + const { product, updateProduct, uniquePermalink, saving, save } = useProductEditContext(); const url = useProductUrl(); const checkoutUrl = useProductUrl({ wanted: true }); - const [match] = useMatches(); - const tab = match?.handle ?? "product"; + const pageComponent = usePage().component; + const tab = React.useMemo(() => { + if (pageComponent === "Products/Content/Edit") return "content"; + if (pageComponent === "Products/Receipt/Edit") return "receipt"; + if (pageComponent === "Products/Share/Edit") return "share"; + return "product"; + }, [pageComponent]); - const navigate = useRefToLatest(useNavigate()); + const navigate = useRefToLatest((url: string) => { + router.get(url); + }); + + const updateUrlForTab = React.useMemo( + () => + tab === "product" + ? Routes.product_product_path(uniquePermalink) + : tab === "content" + ? Routes.product_content_path(uniquePermalink) + : tab === "receipt" + ? Routes.product_receipt_path(uniquePermalink) + : Routes.product_share_path(uniquePermalink), + [tab, uniquePermalink], + ); const [isPublishing, setIsPublishing] = React.useState(false); const setPublished = async (published: boolean) => { - try { - setIsPublishing(true); - await saveProduct(uniquePermalink, id, product, currencyType); - await setProductPublished(uniquePermalink, published); - updateProduct({ is_published: published }); - showAlert(published ? "Published!" : "Unpublished!", "success"); - if (tab === "share") { - if (product.native_type === "coffee") navigate.current(rootPath); - else navigate.current(`${rootPath}/content`); - } else if (published) { - navigate.current(`${rootPath}/share`); + setIsPublishing(true); + if (published) { + try { + await save({ publish: true }); + } catch (e) { + setIsPublishing(false); + showAlert(e instanceof Error ? e.message : "Failed to save and publish", "error"); + return; } - } catch (e) { - assertResponseError(e); - showAlert(e.message, "error", { html: true }); + setIsPublishing(false); + return; } - setIsPublishing(false); + + const onSuccess = () => updateProduct({ is_published: false }); + router.visit(updateUrlForTab, { + method: "patch", + data: { unpublish: true }, + preserveScroll: true, + onSuccess, + onFinish: () => setIsPublishing(false), + }); }; const isUploadingFile = (file: FileEntry | SubtitleFile) => file.status.type === "unsaved" && file.status.uploadStatus.type === "uploading"; const isUploadingFiles = - product.public_files.some((f) => f.status?.type === "unsaved" && f.status.uploadStatus.type === "uploading") || - product.files.some((file) => isUploadingFile(file) || file.subtitle_files.some(isUploadingFile)); + (product.public_files ?? []).some((f) => f.status?.type === "unsaved" && f.status.uploadStatus.type === "uploading") || + (product.files ?? []).some((file) => isUploadingFile(file) || (file.subtitle_files ?? []).some(isUploadingFile)); const imageSettings = useImageUploadSettings(); const isUploadingFilesOrImages = isLoading || isUploadingFiles || !!imageSettings?.isUploading; const isBusy = isUploadingFilesOrImages || saving || isPublishing; @@ -203,7 +222,7 @@ export const Layout = ({ ); - const onTabClick = (e: React.MouseEvent, callback?: () => void) => { + const onTabClick = (e: React.MouseEvent, callback?: () => void) => { const message = isUploadingFiles ? "Some files are still uploading, please wait..." : isUploadingFilesOrImages @@ -251,7 +270,7 @@ export const Layout = ({ @@ -275,37 +294,24 @@ export const Layout = ({ > - + Product {!isCoffee ? ( - + Content ) : null} - + Receipt - { - onTabClick(evt, () => { - if (!product.is_published) { - evt.preventDefault(); - showAlert( - "Not yet! You've got to publish your awesome product before you can share it with your audience and the world.", - "warning", - ); - } - }); - }} - > + Share diff --git a/app/javascript/components/ProductEdit/ProductPreview.tsx b/app/javascript/components/ProductEdit/ProductPreview.tsx index 657301ddfa..e35b38aa4b 100644 --- a/app/javascript/components/ProductEdit/ProductPreview.tsx +++ b/app/javascript/components/ProductEdit/ProductPreview.tsx @@ -40,6 +40,9 @@ export const ProductPreview = ({ showRefundPolicyModal }: { showRefundPolicyModa }; }, [product.default_offer_code]); + const covers = product.covers ?? []; + const variants = product.variants ?? []; + const serializedProduct: Product = { id, name: product.name, @@ -50,8 +53,8 @@ export const ProductPreview = ({ showRefundPolicyModal }: { showRefundPolicyModa profile_url: Routes.root_url({ host: currentSeller.subdomain }), }, collaborating_user: product.collaborating_user, - covers: product.covers, - main_cover_id: product.covers[0]?.id ?? null, + covers, + main_cover_id: covers[0]?.id ?? null, quantity_remaining: product.max_purchase_count !== null ? Math.max(product.max_purchase_count - salesCountForInventory, 0) : null, currency_code: currencyType, @@ -73,14 +76,14 @@ export const ProductPreview = ({ showRefundPolicyModal }: { showRefundPolicyModa is_compliance_blocked: false, is_published: product.is_published, is_stream_only: false, - streamable: product.files.some((file) => file.is_streamable), + streamable: (product.files ?? []).some((file) => file.is_streamable), is_quantity_enabled: product.quantity_enabled, is_multiseat_license: false, hide_sold_out_variants: product.hide_sold_out_variants, sales_count: product.should_show_sales_count ? successfulSalesCount : null, custom_button_text_option: product.custom_button_text_option, summary: product.custom_summary, - attributes: product.custom_attributes, + attributes: product.custom_attributes ?? [], native_type: product.native_type, free_trial: product.free_trial_enabled ? { @@ -92,10 +95,10 @@ export const ProductPreview = ({ showRefundPolicyModal }: { showRefundPolicyModa : null, rental: null, recurrences: - defaultRecurrence && product.variants[0] && "recurrence_price_values" in product.variants[0] + defaultRecurrence && variants[0] && "recurrence_price_values" in variants[0] ? { default: defaultRecurrence, - enabled: Object.entries(product.variants[0].recurrence_price_values).flatMap(([recurrence, value], idx) => + enabled: Object.entries(variants[0].recurrence_price_values).flatMap(([recurrence, value], idx) => value.enabled ? { recurrence, @@ -106,7 +109,7 @@ export const ProductPreview = ({ showRefundPolicyModal }: { showRefundPolicyModa ), } : null, - options: product.variants.map((variant) => ({ + options: variants.map((variant) => ({ ...variant, price_difference_cents: "price_difference_cents" in variant ? variant.price_difference_cents : 0, is_pwyw: "customizable_price" in variant ? variant.customizable_price : product.customizable_price, @@ -140,14 +143,14 @@ export const ProductPreview = ({ showRefundPolicyModal }: { showRefundPolicyModa } : { title: - product.refund_policy.allowed_refund_periods_in_days.find( - ({ key }) => key === product.refund_policy.max_refund_period_in_days, + (product.refund_policy?.allowed_refund_periods_in_days ?? []).find( + ({ key }) => key === product.refund_policy?.max_refund_period_in_days, )?.value ?? "", - fine_print: product.refund_policy.fine_print ?? "", + fine_print: product.refund_policy?.fine_print ?? "", updated_at: "", }, bundle_products: [], - public_files: product.public_files, + public_files: product.public_files ?? [], audio_previews_enabled: product.audio_previews_enabled, }; @@ -158,6 +161,7 @@ export const ProductPreview = ({ showRefundPolicyModal }: { showRefundPolicyModa is_published: true, pwyw: { suggested_price_cents: Math.max( + 0, ...serializedProduct.options.map(({ price_difference_cents }) => price_difference_cents ?? 0), ), }, @@ -188,7 +192,9 @@ export const ProductPreview = ({ showRefundPolicyModal }: { showRefundPolicyModa /> ) : ( <> - + {product.refund_policy != null && ( + + )} void; }) => { + const safeCustomAttributes = customAttributes ?? []; const updateCustomAttribute = (idx: number, update: Partial) => { - const customAttribute = customAttributes[idx]; + const customAttribute = safeCustomAttributes[idx]; if (!customAttribute) return; setCustomAttributes([ - ...customAttributes.slice(0, idx), + ...safeCustomAttributes.slice(0, idx), { ...customAttribute, ...update }, - ...customAttributes.slice(idx + 1), + ...safeCustomAttributes.slice(idx + 1), ]); }; const addButton = ( - @@ -37,7 +38,7 @@ export const AttributesEditor = ({ return (
Additional details - {(fileAttributes?.length ?? 0) > 0 || customAttributes.length > 0 ? ( + {(fileAttributes?.length ?? 0) > 0 || safeCustomAttributes.length > 0 ? ( <> {fileAttributes?.map((attribute, idx) => ( ))} - {customAttributes.map((attribute, idx) => ( + {safeCustomAttributes.map((attribute, idx) => ( updateCustomAttribute(idx, update)} - onDelete={() => setCustomAttributes(customAttributes.filter((_, index) => idx !== index))} + onDelete={() => setCustomAttributes(safeCustomAttributes.filter((_, index) => idx !== index))} key={idx} /> ))} diff --git a/app/javascript/components/ProductEdit/ProductTab/CircleIntegrationEditor.tsx b/app/javascript/components/ProductEdit/ProductTab/CircleIntegrationEditor.tsx index 4101ef01fe..41d46ef59f 100644 --- a/app/javascript/components/ProductEdit/ProductTab/CircleIntegrationEditor.tsx +++ b/app/javascript/components/ProductEdit/ProductTab/CircleIntegrationEditor.tsx @@ -206,9 +206,9 @@ export const CircleIntegrationEditor = ({ Do not remove Circle access when membership ends ) : null} - {product.variants.length > 0 ? ( + {(product.variants ?? []).length > 0 ? ( <> - {product.variants.every(({ integrations }) => !integrations.circle) ? ( + {(product.variants ?? []).every(({ integrations }) => !integrations?.circle) ? ( {product.native_type === "membership" ? "Your integration is not assigned to any tier. Check your tiers' settings." @@ -216,7 +216,7 @@ export const CircleIntegrationEditor = ({ ) : null} integrations.circle)} + checked={(product.variants ?? []).every(({ integrations }) => !!integrations?.circle)} onChange={(e) => setEnabledForOptions(e.target.checked)} label={product.native_type === "membership" ? "Enable for all tiers" : "Enable for all versions"} /> diff --git a/app/javascript/components/ProductEdit/ProductTab/CoverEditor.tsx b/app/javascript/components/ProductEdit/ProductTab/CoverEditor.tsx index 9ade669633..92ec5a5355 100644 --- a/app/javascript/components/ProductEdit/ProductTab/CoverEditor.tsx +++ b/app/javascript/components/ProductEdit/ProductTab/CoverEditor.tsx @@ -34,11 +34,12 @@ export const CoverEditor = ({ setCovers: (covers: AssetPreview[]) => void; permalink: string; }) => { - const [activeCoverId, setActiveCoverId] = React.useState(covers[0]?.id ?? null); + const safeCovers = covers ?? []; + const [activeCoverId, setActiveCoverId] = React.useState(safeCovers[0]?.id ?? null); const [isUploaderOpen, setIsUploaderOpen] = React.useState(false); const [isUploading, setIsUploading] = React.useState(false); - const canAddPreview = covers.length < MAX_PREVIEW_COUNT; + const canAddPreview = safeCovers.length < MAX_PREVIEW_COUNT; const removeCover = async (id: string) => { try { @@ -60,7 +61,7 @@ export const CoverEditor = ({ - {covers.length === 0 ? ( + {safeCovers.length === 0 ? ( - - {covers.map((cover) => ( + + {safeCovers.map((cover) => ( - + )} diff --git a/app/javascript/components/ProductEdit/ProductTab/ThumbnailEditor.tsx b/app/javascript/components/ProductEdit/ProductTab/ThumbnailEditor.tsx index 58243b3bff..34742c3720 100644 --- a/app/javascript/components/ProductEdit/ProductTab/ThumbnailEditor.tsx +++ b/app/javascript/components/ProductEdit/ProductTab/ThumbnailEditor.tsx @@ -35,8 +35,8 @@ const validateFile = async (file: File) => { if (dimensions.height < MIN_SIDE_DIMENSION) throw new ValidationError("Image must be at least 600x600px."); }; -export const coverUrlForThumbnail = (covers: AssetPreview[]) => - covers.find((cover) => cover.type === "image" || cover.type === "unsplash")?.url ?? null; +export const coverUrlForThumbnail = (covers: AssetPreview[] | undefined) => + (covers ?? []).find((cover) => cover.type === "image" || cover.type === "unsplash")?.url ?? null; export const ThumbnailEditor = ({ covers, diff --git a/app/javascript/components/ProductEdit/ProductTab/TiersEditor.tsx b/app/javascript/components/ProductEdit/ProductTab/TiersEditor.tsx index f8f0578f76..98b9c07d4f 100644 --- a/app/javascript/components/ProductEdit/ProductTab/TiersEditor.tsx +++ b/app/javascript/components/ProductEdit/ProductTab/TiersEditor.tsx @@ -43,19 +43,20 @@ const areAllEnabledPricesZero = (recurrencePriceValues: Record void }) => { + const safeTiers = tiers ?? []; const updateVersion = (id: string, update: Partial) => { - onChange(tiers.map((version) => (version.id === id ? { ...version, ...update } : version))); + onChange(safeTiers.map((version) => (version.id === id ? { ...version, ...update } : version))); }; const [deletionModalVersionId, setDeletionModalVersionId] = React.useState(null); - const deletionModalVersion = tiers.find(({ id }) => id === deletionModalVersionId); + const deletionModalVersion = safeTiers.find(({ id }) => id === deletionModalVersionId); const addButton = ( ); - return tiers.length === 0 ? ( + return safeTiers.length === 0 ? (

Offer different tiers of this membership

Sweeten the deal for your customers with different levels of access. Every membership needs at least one tier. @@ -100,7 +101,7 @@ export const TiersEditor = ({ tiers, onChange }: { tiers: Tier[]; onChange: (tie footer={ <> - @@ -112,11 +113,11 @@ export const TiersEditor = ({ tiers, onChange }: { tiers: Tier[]; onChange: (tie ) : null} id)} - onReorder={(newOrder) => onChange(newOrder.flatMap((id) => tiers.find((version) => version.id === id) ?? []))} + currentOrder={safeTiers.map(({ id }) => id)} + onReorder={(newOrder) => onChange(newOrder.flatMap((id) => safeTiers.find((version) => version.id === id) ?? []))} tag={SortableTierEditors} > - {tiers.map((version) => ( + {safeTiers.map((version) => ( enabled) .map(([name]) => name); diff --git a/app/javascript/components/ProductEdit/ProductTab/VersionsEditor.tsx b/app/javascript/components/ProductEdit/ProductTab/VersionsEditor.tsx index f5c6c5a067..b4af39f537 100644 --- a/app/javascript/components/ProductEdit/ProductTab/VersionsEditor.tsx +++ b/app/javascript/components/ProductEdit/ProductTab/VersionsEditor.tsx @@ -124,7 +124,7 @@ const VersionEditor = ({ const url = useProductUrl({ option: version.id }); - const integrations = Object.entries(product.integrations) + const integrations = Object.entries(product.integrations ?? {}) .filter(([_, enabled]) => enabled) .map(([name]) => name); diff --git a/app/javascript/components/ProductEdit/ProductTab/index.tsx b/app/javascript/components/ProductEdit/ProductTab/index.tsx index a371409e66..109a21b3c9 100644 --- a/app/javascript/components/ProductEdit/ProductTab/index.tsx +++ b/app/javascript/components/ProductEdit/ProductTab/index.tsx @@ -8,8 +8,7 @@ import { CopyToClipboard } from "$app/components/CopyToClipboard"; import { useCurrentSeller } from "$app/components/CurrentSeller"; import CustomDomain from "$app/components/CustomDomain"; import { Icon } from "$app/components/Icons"; -import { Layout, useProductUrl } from "$app/components/ProductEdit/Layout"; -import { ProductPreview } from "$app/components/ProductEdit/ProductPreview"; +import { useProductUrl } from "$app/components/ProductEdit/Layout"; import { AttributesEditor } from "$app/components/ProductEdit/ProductTab/AttributesEditor"; import { AvailabilityEditor } from "$app/components/ProductEdit/ProductTab/AvailabilityEditor"; import { BundleConversionNotice } from "$app/components/ProductEdit/ProductTab/BundleConversionNotice"; @@ -61,13 +60,14 @@ export const ProductTab = () => { aiGenerated, } = useProductEditContext(); const [initialProduct] = React.useState(product); + const integrations = product.integrations ?? {}; const [thumbnail, setThumbnail] = React.useState(initialThumbnail); const [showAiNotification, setShowAiNotification] = React.useState(aiGenerated); - const { isUploading, setImagesUploading } = useImageUpload(); + const { setImagesUploading } = useImageUpload(); - const [showRefundPolicyPreview, setShowRefundPolicyPreview] = React.useState(false); + const [, setShowRefundPolicyPreview] = React.useState(false); const isCoffee = product.native_type === "coffee"; @@ -76,8 +76,7 @@ export const ProductTab = () => { if (!currentSeller) return null; return ( - } isLoading={isUploading}> -
+
{showAiNotification ? ( @@ -217,22 +216,22 @@ export const ProductTab = () => { /> )} updateProduct({ integrations: { - ...product.integrations, + ...integrations, circle: newIntegration, }, }) } /> updateProduct({ integrations: { - ...product.integrations, + ...integrations, discord: newIntegration, }, }) @@ -240,11 +239,11 @@ export const ProductTab = () => { /> {product.native_type === "call" && googleCalendarEnabled ? ( updateProduct({ integrations: { - ...product.integrations, + ...integrations, google_calendar: newIntegration, }, }) @@ -406,7 +405,7 @@ export const ProductTab = () => { /> ) : null} - {product.variants.length > 0 ? ( + {(product.variants ?? []).length > 0 ? ( updateProduct({ hide_sold_out_variants: e.target.checked })} @@ -481,6 +480,5 @@ export const ProductTab = () => { )}
- ); }; diff --git a/app/javascript/components/ProductEdit/ReceiptTab/index.tsx b/app/javascript/components/ProductEdit/ReceiptTab/index.tsx index 6fed230922..376a068caf 100644 --- a/app/javascript/components/ProductEdit/ReceiptTab/index.tsx +++ b/app/javascript/components/ProductEdit/ReceiptTab/index.tsx @@ -1,7 +1,5 @@ import * as React from "react"; -import { Layout } from "$app/components/ProductEdit/Layout"; -import { ReceiptPreview } from "$app/components/ProductEdit/ReceiptPreview"; import { CustomReceiptTextInput } from "$app/components/ProductEdit/ReceiptTab/CustomReceiptTextInput"; import { CustomViewContentButtonTextInput } from "$app/components/ProductEdit/ReceiptTab/CustomViewContentButtonTextInput"; import { useProductEditContext } from "$app/components/ProductEdit/state"; @@ -10,8 +8,7 @@ export const ReceiptTab = () => { const { product, updateProduct } = useProductEditContext(); return ( - } previewScaleFactor={1} showBorder={false} showNavigationButton={false}> -
+
{
- ); }; diff --git a/app/javascript/components/ProductEdit/RefundPolicy.tsx b/app/javascript/components/ProductEdit/RefundPolicy.tsx index 0be5c9dbb9..c3c1d3a6c1 100644 --- a/app/javascript/components/ProductEdit/RefundPolicy.tsx +++ b/app/javascript/components/ProductEdit/RefundPolicy.tsx @@ -20,6 +20,14 @@ export type RefundPolicy = { title: string; }; +const defaultRefundPolicy: RefundPolicy = { + title: "", + fine_print: null, + fine_print_enabled: false, + max_refund_period_in_days: 0, + allowed_refund_periods_in_days: [], +}; + export const RefundPolicySelector = ({ refundPolicy, setRefundPolicy, @@ -28,7 +36,7 @@ export const RefundPolicySelector = ({ setIsEnabled, setShowPreview, }: { - refundPolicy: RefundPolicy; + refundPolicy?: RefundPolicy | null; setRefundPolicy: (refundPolicy: RefundPolicy) => void; refundPolicies: OtherRefundPolicy[]; isEnabled: boolean; @@ -36,6 +44,7 @@ export const RefundPolicySelector = ({ setShowPreview: (showingPreview: boolean) => void; }) => { const [selectedRefundPolicyId, setSelectedRefundPolicyId] = React.useState(null); + const safeRefundPolicy = refundPolicy ?? defaultRefundPolicy; const uid = React.useId(); @@ -81,7 +90,7 @@ export const RefundPolicySelector = ({ const otherRefundPolicy = refundPolicies.find(({ id }) => id === selectedRefundPolicyId); if (otherRefundPolicy) { setRefundPolicy({ - ...refundPolicy, + ...safeRefundPolicy, title: otherRefundPolicy.title, fine_print: otherRefundPolicy.fine_print, max_refund_period_in_days: otherRefundPolicy.max_refund_period_in_days, @@ -99,20 +108,20 @@ export const RefundPolicySelector = ({