diff --git a/Gemfile b/Gemfile index 51145a7..8543d46 100644 --- a/Gemfile +++ b/Gemfile @@ -33,6 +33,8 @@ gem "cssbundling-rails" # Build JSON APIs with ease [https://github.com/rails/jbuilder] gem "jbuilder" +gem "country_select" + # gem "tailwindcss-rails" # Use Redis adapter to run Action Cable in production @@ -143,6 +145,7 @@ group :development, :test do end gem 'ransack' +gem "paranoia" gem "rails_autolink", "~> 1.1" @@ -166,3 +169,5 @@ gem "rubyzip", "~> 2.3" gem "sequel", "~> 5.71" gem "mrsk", "~> 0.15.1" + +gem 'backstage-rails', path: 'backstage-rails' \ No newline at end of file diff --git a/Gemfile.lock b/Gemfile.lock index 305b8e7..fcf6bda 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -87,6 +87,12 @@ GIT specs: rspec-support (3.13.0.pre) +PATH + remote: backstage-rails + specs: + backstage-rails (0.1.0) + rails (>= 7.1.3.2) + GEM remote: https://rubygems.org/ specs: @@ -237,6 +243,10 @@ GEM coderay (1.1.3) concurrent-ruby (1.3.3) connection_pool (2.4.1) + countries (6.0.1) + unaccent (~> 0.3) + country_select (9.0.0) + countries (> 5.0, < 7.0) crass (1.0.6) cssbundling-rails (1.2.0) railties (>= 6.0.0) @@ -428,6 +438,8 @@ GEM rack orm_adapter (0.5.0) parallel (1.23.0) + paranoia (2.6.3) + activerecord (>= 5.1, < 7.2) parser (3.2.2.3) ast (~> 2.4.1) racc @@ -603,6 +615,7 @@ GEM json (~> 2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) + unaccent (0.4.0) unf (0.1.4) unf_ext unf_ext (0.0.8.2) @@ -642,9 +655,11 @@ DEPENDENCIES acts_as_list (~> 0.9.19) aws-sdk-rails aws-sdk-s3 (~> 1.48) + backstage-rails! bootsnap browser (~> 5.3) capybara + country_select cssbundling-rails database_cleaner database_cleaner-active_record @@ -672,6 +687,7 @@ DEPENDENCIES omniauth-rails_csrf_protection omniauth-twitch omniauth-twitter + paranoia pg (~> 1.1) pry puma diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 675465a..5146490 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -4,7 +4,7 @@ @layer components { .ProseMirror { - @apply text-default; + @apply text-default focus:outline-none outline-none dark:prose-invert; } :root { /* background */ diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb deleted file mode 100644 index ee0db7d..0000000 --- a/app/controllers/admin/base_controller.rb +++ /dev/null @@ -1,24 +0,0 @@ -class Admin::BaseController < ApplicationController - layout 'admin' - before_action :authenticate_admin! - before_action :set_resource_config - helper_method :resource_class - - private - - def default_render - if lookup_context.template_exists?(action_name, "admin/#{controller_name}", true) - super - else - render template: "admin/default/#{action_name}" - end - end - - def authenticate_admin! - redirect_to root_path, alert: 'Access denied.' unless current_user&.is_admin? - end - - def set_resource_config - @resource_config = Admin::Config.resources[controller_name.to_sym] - end -end \ No newline at end of file diff --git a/app/controllers/admin/base_dashboard_controller.rb b/app/controllers/admin/base_dashboard_controller.rb deleted file mode 100644 index 29219f0..0000000 --- a/app/controllers/admin/base_dashboard_controller.rb +++ /dev/null @@ -1,10 +0,0 @@ -class Admin::BaseDashboardController < ApplicationController - layout 'admin' - before_action :authenticate_admin! - - private - - def authenticate_admin! - redirect_to root_path, alert: 'Access denied.' unless current_user&.is_admin? - end -end \ No newline at end of file diff --git a/app/controllers/admin/categories_controller.rb b/app/controllers/admin/categories_controller.rb index eee2415..63fd1e3 100644 --- a/app/controllers/admin/categories_controller.rb +++ b/app/controllers/admin/categories_controller.rb @@ -1,5 +1,4 @@ -class Admin::CategoriesController < Admin::BaseController - include AdminControllerConcern +class Admin::CategoriesController < Backstage::Rails::BaseController private diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 13556ad..043003d 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -1,4 +1,4 @@ -class Admin::DashboardController < Admin::BaseDashboardController +class Admin::DashboardController < Backstage::Rails::BaseController def index readme_path = Rails.root.join('app', 'views', 'admin', 'README.md') @readme_content = File.exist?(readme_path) ? File.read(readme_path) : "Welcome to the Admin Panel" diff --git a/app/controllers/admin/posts_controller.rb b/app/controllers/admin/posts_controller.rb index 6b2ee3e..a28a8b7 100644 --- a/app/controllers/admin/posts_controller.rb +++ b/app/controllers/admin/posts_controller.rb @@ -1,5 +1,4 @@ -class Admin::PostsController < Admin::BaseController - include AdminControllerConcern +class Admin::PostsController < Backstage::Rails::BaseController private diff --git a/app/controllers/admin/terms_and_conditions_controller.rb b/app/controllers/admin/terms_and_conditions_controller.rb new file mode 100644 index 0000000..2d5c1c6 --- /dev/null +++ b/app/controllers/admin/terms_and_conditions_controller.rb @@ -0,0 +1,12 @@ +class Admin::TermsAndConditionsController < Backstage::Rails::BaseController + + private + + def model_class + TermsAndCondition + end + + def permitted_params + params.require(:terms_and_condition).permit(:title, :category, :content) + end +end \ No newline at end of file diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 8fb6132..3a0ef31 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -1,5 +1,4 @@ -class Admin::UsersController < Admin::BaseController - include AdminControllerConcern +class Admin::UsersController < Backstage::Rails::BaseController private diff --git a/app/controllers/concerns/admin_controller_concern.rb b/app/controllers/concerns/admin_controller_concern.rb deleted file mode 100644 index a755929..0000000 --- a/app/controllers/concerns/admin_controller_concern.rb +++ /dev/null @@ -1,64 +0,0 @@ -module AdminControllerConcern - extend ActiveSupport::Concern - - included do - before_action :set_resource, only: [:show, :edit, :update, :destroy] - end - - def index - @q = model_class.ransack(params[:q]) - @resources = @q.result.page(params[:page]) - end - - def show - end - - def new - @resource = model_class.new - end - - def edit - end - - def create - @resource = model_class.new(permitted_params) - - if @resource.save - redirect_to [:admin, @resource], notice: "#{model_class.name} was successfully created." - else - render :new - end - end - - def update - if @resource.update(permitted_params) - redirect_to [:admin, @resource], notice: "#{model_class.name} was successfully updated." - else - Rails.logger.error(@resource.errors.full_messages) - render :edit - end - end - - def destroy - @resource.destroy - redirect_to [:admin, model_class], notice: "#{model_class.name} was successfully destroyed." - end - - private - - def set_resource - if model_class.respond_to?(:friendly) - @resource = model_class.friendly.find(params[:id]) - else - @resource = model_class.find(params[:id]) - end - end - - def model_class - raise NotImplementedError, "Subclasses must define model_class" - end - - def permitted_params - raise NotImplementedError, "Subclasses must define permitted_params" - end -end \ No newline at end of file diff --git a/app/controllers/event_streaming_services_controller.rb b/app/controllers/event_streaming_services_controller.rb index 0e5f939..16c0319 100644 --- a/app/controllers/event_streaming_services_controller.rb +++ b/app/controllers/event_streaming_services_controller.rb @@ -5,7 +5,7 @@ def show @event = current_user.events.find_signed(params[:id]) @provider = @event.streaming_service["name"] @service_klass = StreamingProviders::Service.find_module_by_type(@provider) - @service = @service_klass.new(@event.streaming_service) + @service = @service_klass&.new(@event.streaming_service) end def new diff --git a/app/controllers/product_cart_controller.rb b/app/controllers/product_cart_controller.rb new file mode 100644 index 0000000..d552c22 --- /dev/null +++ b/app/controllers/product_cart_controller.rb @@ -0,0 +1,30 @@ +# app/controllers/product_cart_controller.rb +class ProductCartController < ApplicationController + include ApplicationHelper # This gives us access to the current_cart method + before_action :set_cart + + def add + product = Product.find(params[:product_id]) + @cart.add_product(product) + redirect_back(fallback_location: root_path, notice: 'Item added to cart') + end + + def show + @cart_items = @cart.product_cart_items.includes(:product) + end + + def remove + item = @cart.product_cart_items.find_by(product_id: params[:product_id]) + item.destroy if item + redirect_to( product_cart_path, notice: 'Item removed from cart') + end + + private + + def set_cart + @cart = current_cart + if @cart.blank? + redirect_to root_path, notice: "Log in first to access your cart" and return + end + end +end \ No newline at end of file diff --git a/app/controllers/product_checkout_controller.rb b/app/controllers/product_checkout_controller.rb new file mode 100644 index 0000000..0308a69 --- /dev/null +++ b/app/controllers/product_checkout_controller.rb @@ -0,0 +1,168 @@ +# app/controllers/product_checkout_controller.rb +class ProductCheckoutController < ApplicationController + before_action :set_cart + + def create + cart_items = @cart.product_cart_items.includes(:product).map do |item| + { + price_data: { + currency: 'usd', + product_data: { + name: item.product.title, + }, + unit_amount: (item.product.price * 100).to_i, + }, + quantity: item.quantity, + } + end + + @purchase = current_user.product_purchases.create( + total_amount: @cart.total_price, + status: :pending + ) + + shipping_countries = @cart.product_cart_items.map(&:product).flat_map do |product| + product.product_shippings.pluck(:country) + end.uniq + + session = Stripe::Checkout::Session.create({ + payment_method_types: ['card'], + line_items: cart_items, + mode: 'payment', + success_url: success_url(purchase_id: @purchase.id), + cancel_url: cancel_url, + client_reference_id: @cart.id.to_s, + customer_email: current_user.email, + tax_id_collection: {enabled: true}, + metadata: { + purchase_id: @purchase.id, + cart_id: @cart.id + }, + shipping_address_collection: { + allowed_countries: shipping_countries + }, + phone_number_collection: { + enabled: true + }, + shipping_options: generate_shipping_options, + }) + + @purchase.update(stripe_session_id: session.id) + redirect_to session.url, allow_other_host: true + end + + def success + @purchase = ProductPurchase.find(params[:purchase_id]) + + if @purchase.pending? + stripe_session = Stripe::Checkout::Session.retrieve(@purchase.stripe_session_id) + + if stripe_session.payment_status == 'paid' + shipping_cost = stripe_session.shipping_cost.amount_total / 100.0 + total_amount = stripe_session.amount_total / 100.0 + + payment_intent = Stripe::PaymentIntent.retrieve(stripe_session.payment_intent) + + @purchase.update( + status: :completed, + shipping_address: stripe_session.shipping_details.address.to_h, + shipping_name: stripe_session.shipping_details.name, + phone: stripe_session.customer_details.phone, + shipping_cost: shipping_cost, + total_amount: total_amount, + payment_intent_id: payment_intent["id"] + ) + + cart = ProductCart.find(stripe_session.metadata.cart_id) + + @purchase.product_purchase_items.create(cart.product_cart_items.map { |item| + product = item.product + shipping = product.product_shippings.find_by(country: stripe_session.shipping_details.address.country) || + product.product_shippings.find_by(country: 'Rest of World') + + additional_shipping_cost = shipping ? (item.quantity - 1) * shipping.additional_cost : 0 + + { + product: product, + quantity: item.quantity, + price: product.price, + shipping_cost: (shipping&.base_cost || 0) + additional_shipping_cost + } + }) + + @purchase.product_purchase_items.each do |item| + item.product.decrease_quantity(item.quantity) + end + + ProductPurchaseMailer.purchase_confirmation(@purchase).deliver_later + + cart.product_cart_items.destroy_all + + redirect_to root_path, notice: 'Payment successful! Thank you for your purchase.' + else + @purchase.update(status: :failed) + redirect_to product_cart_path, alert: 'Payment was not successful. Please try again.' + end + else + redirect_to root_path, notice: 'This purchase has already been processed.' + end + end + + def cancel + redirect_to product_cart_path, alert: 'Payment cancelled.' + end + + private + + def set_cart + @cart = current_cart + end + + def current_cart + ProductCart.find(session[:cart_id]) + rescue ActiveRecord::RecordNotFound + cart = ProductCart.create(user: current_user) + session[:cart_id] = cart.id + cart + end + + def success_url(options) + success_product_checkout_index_url(options) + end + + def cancel_url + cancel_product_checkout_index_url + end + + def generate_shipping_options + shipping_options = [] + + @cart.product_cart_items.map(&:product).each do |product| + product.product_shippings.each do |shipping| + option = { + shipping_rate_data: { + type: 'fixed_amount', + fixed_amount: { + amount: (shipping.base_cost * 100).to_i, + currency: 'usd', + }, + display_name: "Shipping to #{shipping.country}", + delivery_estimate: { + minimum: { + unit: 'business_day', + value: 5, + }, + maximum: { + unit: 'business_day', + value: 10, + }, + }, + }, + } + shipping_options << option unless shipping_options.any? { |o| o[:shipping_rate_data][:display_name] == option[:shipping_rate_data][:display_name] } + end + end + + shipping_options + end +end \ No newline at end of file diff --git a/app/controllers/product_purchases_controller.rb b/app/controllers/product_purchases_controller.rb new file mode 100644 index 0000000..0825e28 --- /dev/null +++ b/app/controllers/product_purchases_controller.rb @@ -0,0 +1,11 @@ +class ProductPurchasesController < ApplicationController + before_action :authenticate_user! + + def index + @purchases = current_user.product_purchases.order(created_at: :desc) + end + + def show + @purchase = current_user.product_purchases.find(params[:id]) + end +end diff --git a/app/controllers/product_variants_controller.rb b/app/controllers/product_variants_controller.rb new file mode 100644 index 0000000..3c46ef4 --- /dev/null +++ b/app/controllers/product_variants_controller.rb @@ -0,0 +1,10 @@ +class ProductVariantsController < ApplicationController + def create + end + + def update + end + + def destroy + end +end diff --git a/app/controllers/products_controller.rb b/app/controllers/products_controller.rb new file mode 100644 index 0000000..fa2d331 --- /dev/null +++ b/app/controllers/products_controller.rb @@ -0,0 +1,95 @@ +# app/controllers/products_controller.rb +class ProductsController < ApplicationController + before_action :authenticate_user!, except: [:index, :show] + before_action :set_product, only: [:show, :edit, :update, :destroy] + before_action :authorize_user, only: [:edit, :update, :destroy] + + def index + @profile = User.find_by(username: params[:id] || params[:user_id]) + + #@profile = User.find_by(username: params[:username]) || current_user + @q = @profile.products.active.includes(:user, :album).ransack(params[:q]) + @products = @q.result(distinct: true).order(created_at: :desc) + + #@products = @profile.products.active.includes(:user, :album).order(created_at: :desc) + @products = @products.by_category(params[:category]) if params[:category].present? + @products = @products.page(params[:page]).per(20) # Assuming you're using Kaminari for pagination + end + + def show + @product_variants = @product.product_variants + + + if request.headers["Turbo-Frame"] == "gallery-photo" + product_image = @product.product_images.find(params[:image]) + render turbo_stream: [ + turbo_stream.update( + "gallery-photo", + partial: "gallery_photo", + locals: { image: product_image.image } + ) + ] and return + end + end + + def new + @product = current_user.products.new + end + + def create + @product = current_user.products.new(product_params) + + if @product.save + redirect_to @product, notice: 'Product was successfully created.' + else + render :new, status: 422 + end + end + + def edit + end + + def update + if @product.update(product_params) + redirect_to @product, notice: 'Product was successfully updated.' + else + Rails.logger.error("AAA #{@product.errors.full_messages}") + render :edit, status: 422 + end + end + + def destroy + @product.destroy + redirect_to products_url, notice: 'Product was successfully deleted.' + end + + private + + def set_product + @product = Product.friendly.find(params[:id]) + end + + def authorize_user + unless @product.user == current_user || current_user.is_admin? + redirect_to products_path, alert: 'You are not authorized to perform this action.' + end + end + + def product_params + params.require(:product).permit( + :title, + :limited_edition, :limited_edition_count, :include_digital_album, :visibility, + :name_your_price, :shipping_days, :shipping_begins_on, :shipping_within_country_price, + :shipping_worldwide_price, :quantity, :playlist_id, + :title, :description, :price, :sku, :category, :status, :stock_quantity, + :limited_edition, :limited_edition_count, :include_digital_album, + :visibility, :name_your_price, :shipping_days, :shipping_begins_on, + :shipping_within_country_price, :shipping_worldwide_price, :quantity, + :shipping_days, + images: [], product_variants_attributes: [:id, :name, :price, :stock_quantity, :_destroy], + product_options_attributes: [:id, :name, :quantity, :sku, :_destroy], + product_images_attributes: [:id, :name, :description, :image, :_destroy], + product_shippings_attributes: [:id, :country, :base_cost, :additional_cost, :_destroy] + ) + end +end \ No newline at end of file diff --git a/app/controllers/sales_controller.rb b/app/controllers/sales_controller.rb index bb5d5cf..2d4b794 100644 --- a/app/controllers/sales_controller.rb +++ b/app/controllers/sales_controller.rb @@ -1,8 +1,86 @@ class SalesController < ApplicationController before_action :authenticate_user! + before_action :ensure_seller + before_action :set_purchase, only: [:product_show, :update, :refund] def index - @tab = (params[:tab] == "tracks") ? "Track" : "Album" - @collection = current_user.user_sales_for(@tab) + @tab = params[:tab].present? ? params[:tab].singularize.capitalize : "Album" + + if @tab == "Product" + @collection = ProductPurchase.for_seller(current_user) + .order(created_at: :desc) + .page(params[:page]) + else + @collection = current_user.user_sales_for(@tab) + end + end + + def show + unless @purchase.product_purchase_items.joins(:product).where(products: { user_id: current_user.seller_account_ids }).exists? + redirect_to admin_product_purchases_path, alert: 'Access denied.' + end + end + + def product_show + @product_item = ProductPurchase.for_seller(current_user).find_by(id: params[:id]) + @product = @product_item.products.first + unless @product_item + redirect_to sales_path, alert: 'Product not found in this purchase.' + end + end + + def update + if @purchase.update(purchase_params) + if @purchase.saved_change_to_shipping_status? or + @purchase.saved_change_to_tracking_code? or + @purchase.saved_change_to_status? + ProductPurchaseMailer.status_update(@purchase).deliver_later + end + # flash[:notice] = 'Purchase was successfully updated.' + redirect_to sales_path(tab: "Product"), notice: 'Purchase was successfully updated.' + else + # render :product_show + # flash[:notice] = 'no.' + redirect_to sales_path(tab: "Product"), notice: 'no' + end + end + + def refund + # @product_item = @purchase.product_purchase_items.find(params[:id]) + + stripe_refund = Stripe::Refund.create({ + payment_intent: @purchase.payment_intent_id, + # amount: (@product_item.price * 100).to_i # Refund amount in cents + }) + + Rails.logger.info(stripe_refund) + if stripe_refund.status == 'succeeded' + @purchase.update(status: :refunded) + # @product_item.update(refunded: true) + flash.now[:notice] = 'Product was successfully refunded.' + # redirect_to product_show_sale_path(@purchase), notice: 'Product was successfully refunded.' + else + flash.now[:notice] = 'Refund failed. Please try again.' + # redirect_to product_show_sale_path(@purchase), alert: 'Refund failed. Please try again.' + end + rescue Stripe::StripeError => e + flash.now[:notice] = "Refund failed: #{e.message}" + # redirect_to product_show_sale_path(@purchase), alert: "Refund failed: #{e.message}" + end + + private + + def ensure_seller + redirect_to root_path, alert: 'Access denied.' unless current_user.can_sell_products? + end + + def set_purchase + @purchase = ProductPurchase.for_seller(current_user).find(params[:id]) + rescue ActiveRecord::RecordNotFound + redirect_to admin_product_purchases_path, alert: 'Purchase not found.' + end + + def purchase_params + params.require(:product_purchase).permit(:shipping_status, :tracking_code) end end diff --git a/app/controllers/terms_and_conditions_controller.rb b/app/controllers/terms_and_conditions_controller.rb new file mode 100644 index 0000000..f6a022f --- /dev/null +++ b/app/controllers/terms_and_conditions_controller.rb @@ -0,0 +1,13 @@ +class TermsAndConditionsController < ApplicationController + def index + @terms = if params[:category] + TermsAndCondition.where(category: params[:category]) + else + TermsAndCondition.all + end + end + + def show + @term = TermsAndCondition.find(params[:id]) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 2864ea6..31829eb 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,6 +1,19 @@ module ApplicationHelper ActionView::Base.default_form_builder = TailwindFormBuilder + def current_cart + ProductCart.find(session[:cart_id]) + rescue ActiveRecord::RecordNotFound + return if current_user.blank? + cart = ProductCart.create(user: current_user) + session[:cart_id] = cart.id + cart + end + + def cart_item_count + current_cart&.product_cart_items&.sum(:quantity) || 0 + end + def gettext(text) text end diff --git a/app/helpers/product_cart_helper.rb b/app/helpers/product_cart_helper.rb new file mode 100644 index 0000000..b769f78 --- /dev/null +++ b/app/helpers/product_cart_helper.rb @@ -0,0 +1,2 @@ +module ProductCartHelper +end diff --git a/app/helpers/product_checkout_helper.rb b/app/helpers/product_checkout_helper.rb new file mode 100644 index 0000000..1c8f7f1 --- /dev/null +++ b/app/helpers/product_checkout_helper.rb @@ -0,0 +1,2 @@ +module ProductCheckoutHelper +end diff --git a/app/helpers/product_purchases_helper.rb b/app/helpers/product_purchases_helper.rb new file mode 100644 index 0000000..19d02ff --- /dev/null +++ b/app/helpers/product_purchases_helper.rb @@ -0,0 +1,22 @@ +module ProductPurchasesHelper + + def shipping_status_progress(status) + statuses = ProductPurchase.shipping_statuses.keys + current_index = statuses.index(status.to_s) + total_statuses = statuses.length + + return 0 if current_index.nil? + + ((current_index + 1).to_f / total_statuses * 100).round + end + + def shipping_status_class(status, current_status) + return "" if current_status.blank? + if ProductPurchase.shipping_statuses[current_status] <= ProductPurchase.shipping_statuses[status] + "text-indigo-600" + else + "text-gray-500" + end + end + +end diff --git a/app/helpers/product_variants_helper.rb b/app/helpers/product_variants_helper.rb new file mode 100644 index 0000000..ceb4103 --- /dev/null +++ b/app/helpers/product_variants_helper.rb @@ -0,0 +1,2 @@ +module ProductVariantsHelper +end diff --git a/app/helpers/products_helper.rb b/app/helpers/products_helper.rb new file mode 100644 index 0000000..ab5c42b --- /dev/null +++ b/app/helpers/products_helper.rb @@ -0,0 +1,2 @@ +module ProductsHelper +end diff --git a/app/helpers/tailwind_form_builder.rb b/app/helpers/tailwind_form_builder.rb index 7be1e42..afa2ae4 100644 --- a/app/helpers/tailwind_form_builder.rb +++ b/app/helpers/tailwind_form_builder.rb @@ -132,7 +132,7 @@ def radio_button(method, value, options = {}) ) end - @template.tag.div(class: "inline-flex items-center #{options[:wrapper_class]}") do + @template.tag.div(class: "inline-flex space-x-2 items-center #{options[:wrapper_class]}") do super + label_content + @template.tag.div(class: "text-sm font-normal leading-5 text-muted") do field_details(method, object, options) @@ -146,7 +146,7 @@ def check_box(method, options = {}, checked_value = "1", unchecked_value = "0") unless options[:label] == false info = @template.label_tag( tr(options[:label] || method), nil, - class: "block text-gray-500 dark:text-white text-sm font-normal" + class: "block text-gray-500 dark:text-white text-md font-bold pt-1" ) + field_details(method, object, options) end @@ -154,7 +154,7 @@ def check_box(method, options = {}, checked_value = "1", unchecked_value = "0") options.merge!(class: "self-center mt-1-- mr-2 form-checkbox h-4 w-4 text-indigo-600 transition duration-150 ease-in-out") unless options.key?(:class) # Create the checkbox and, conditionally, its label - @template.tag.div(class: "flex items-center") do + @template.tag.div(class: "flex items-center space-x-2") do @template.check_box( @object_name, method, objectify_options(options), checked_value, unchecked_value ) + (info.present? ? @template.tag.div(class: "flex-col items-center") { info } : "".html_safe) diff --git a/app/helpers/terms_and_conditions_helper.rb b/app/helpers/terms_and_conditions_helper.rb new file mode 100644 index 0000000..f760a0f --- /dev/null +++ b/app/helpers/terms_and_conditions_helper.rb @@ -0,0 +1,9 @@ +module TermsAndConditionsHelper + + def terms_and_conditions_menu + TermsAndCondition.pluck(:category).uniq.map do |category| + link_to category.titleize, terms_and_conditions_path(category: category) + end.join(' | ').html_safe + end + +end diff --git a/app/javascript/controllers/filter_manager_controller.js b/app/javascript/controllers/filter_manager_controller.js new file mode 100644 index 0000000..3e17e1b --- /dev/null +++ b/app/javascript/controllers/filter_manager_controller.js @@ -0,0 +1,67 @@ +// app/javascript/controllers/filter_manager_controller.js +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["filterForm", "filterList", "filterItem"] + + connect() { + this.filterCount = this.filterItemTargets.length + } + + addFilter(event) { + event.preventDefault() + const url = `/admin/${this.element.dataset.resourceName}/add_filter` + const formData = new FormData(this.filterFormTarget) + + fetch(url, { + method: 'POST', + headers: { + 'Accept': 'text/vnd.turbo-stream.html', + 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content + }, + body: formData + }) + .then(response => response.text()) + .then(html => Turbo.renderStreamMessage(html)) + this.filterCount++ + } + + removeFilter(event) { + event.preventDefault() + const filterItem = event.target.closest('.filter-item') + filterItem.remove() + this.updateFilterIndices() + } + + clearAll(event) { + event.preventDefault() + this.filterFormTarget.reset() + this.filterItemTargets.slice(1).forEach(item => item.remove()) + this.filterCount = 1 + } + + updateFilterIndices() { + this.filterItemTargets.forEach((item, index) => { + item.querySelectorAll('select, input').forEach(element => { + const name = element.getAttribute('name') + if (name) { + element.setAttribute('name', name.replace(/\[\d+\]/, `[${index}]`)) + } + }) + const conditionSelect = item.querySelector('select[name$="[condition]"]') + if (index === 0 && conditionSelect) { + conditionSelect.closest('.mb-2').remove() + } else if (index > 0 && !conditionSelect) { + item.insertAdjacentHTML('afterbegin', ` +
+ +
+ `) + } + }) + this.filterCount = this.filterItemTargets.length + } +} \ No newline at end of file diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 4bc5289..7e0cb82 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -40,12 +40,16 @@ import HwComboboxController from "@josefarias/hotwire_combobox" import submit_on_change_controller from "./submit_on_change_controller.js" import input_listener_controller from "./input_listener_controller.js" import dark_mode_controller from "./dark_mode_controller.js" +import simple_editor_controller from "./simple_editor_controller.jsx" +import filter_manager_controller from "./filter_manager_controller.js" //import GeoChart from './geo_chart_controller' // Configure Stimulus development experience application.debug = true // window.Stimulus = application +application.register("filter-manager", filter_manager_controller) +application.register("simple-editor", simple_editor_controller) application.register("dropdown", Dropdown) application.register("audio", Audio) application.register("tabs", Tabs) diff --git a/app/javascript/controllers/simple_editor_controller.jsx b/app/javascript/controllers/simple_editor_controller.jsx new file mode 100644 index 0000000..e0bc444 --- /dev/null +++ b/app/javascript/controllers/simple_editor_controller.jsx @@ -0,0 +1,161 @@ + +import { Controller } from '@hotwired/stimulus'; + + +import Document from '@tiptap/extension-document' +import Paragraph from '@tiptap/extension-paragraph' +import Text from '@tiptap/extension-text' +import { Editor } from '@tiptap/core' +import Heading from '@tiptap/extension-heading' +import Bold from '@tiptap/extension-bold' +import Italic from '@tiptap/extension-italic' +import Link from '@tiptap/extension-link' +import ListItem from '@tiptap/extension-list-item' +import OrderedList from '@tiptap/extension-ordered-list' +import BulletList from "@tiptap/extension-bullet-list" +import History from '@tiptap/extension-history' +import { generateHTML, generateJSON } from '@tiptap/html' + +export default class extends Controller { + + static targets = ["editor", "linkField", "linkWrapper", "textInput"] + + initialize(){ + + const textInput = this.textInputTarget + + const richExtensions = [ + Document, + Paragraph, + Text, + Bold, + Italic, + Link, + OrderedList, + ListItem, + BulletList, + History.configure({ + depth: 10, + }), + Heading.configure({ + levels: [1, 2, 3], + }) + ] + + const plainExtensions = [ + Document, + Paragraph, + Text, + ] + + const extensions = this.element.dataset.plain ? + plainExtensions : richExtensions + + this.editor = new Editor({ + element: this.editorTarget, + extensions: extensions, + // all your other extensions + onBeforeCreate({ editor }) { + // Before the view is created. + }, + onCreate({ editor }) { + // The editor is ready. + editor.commands.insertContent(textInput.value) + }, + onUpdate({ editor }) { + // The content has changed. + console.log("updated", editor.state.toJSON()) + + const html = generateHTML(editor.state.toJSON().doc, [ + Document, + Paragraph, + Text, + Bold, + Italic, + Link, + OrderedList, + ListItem, + BulletList, + History.configure({ + depth: 10, + }), + Heading.configure({ + levels: [1, 2, 3], + }), + ]) + + console.log("HTML", html) + textInput.value = html + }, + onSelectionUpdate({ editor }) { + // The selection has changed. + }, + onTransaction({ editor, transaction }) { + // The editor state has changed. + }, + onFocus({ editor, event }) { + // The editor is focused. + }, + onBlur({ editor, event }) { + // The editor isn’t focused anymore. + }, + onDestroy() { + // The editor is being destroyed. + }, + }) + + //this.editor.insertContent(textInput.value) + //this.editor.commands.setContent(this.textInputTarget.value) + window.ed = this.editor + } + + toggleBold(e){ + e.preventDefault() + this.editor.commands.toggleBold() + } + + toggleItalic(e){ + e.preventDefault() + this.editor.commands.toggleItalic() + } + + toggleHeading(e){ + e.preventDefault() + this.editor.commands.toggleHeading({ level: parseInt(e.currentTarget.dataset.level) }) + } + + toggleOrderedList(e){ + e.preventDefault() + this.editor.commands.toggleOrderedList() + } + + toggleBulletList(e){ + e.preventDefault() + this.editor.commands.toggleBulletList() + } + + insert(e){ + e.preventDefault() + this.editor.commands.insertContent(e.currentTarget.dataset.value) + } + + setLink(e){ + e.preventDefault() + this.editor.commands.toggleLink({ href: this.linkFieldTarget.value, target: '_blank' }) + } + + + toggleLink(e){ + e.preventDefault() + this.editor.commands.toggleLink({ href: this.linkFieldTarget.value, target: '_blank' }) + } + + openLinkPrompt(e){ + e.preventDefault() + this.linkWrapperTarget.classList.toggle("hidden") + } + + disconnect(){ + this.editor && this.editor.cleanup() + } +} \ No newline at end of file diff --git a/app/mailers/product_purchase_mailer.rb b/app/mailers/product_purchase_mailer.rb new file mode 100644 index 0000000..bb82d24 --- /dev/null +++ b/app/mailers/product_purchase_mailer.rb @@ -0,0 +1,13 @@ +class ProductPurchaseMailer < ApplicationMailer + def purchase_confirmation(purchase) + @purchase = purchase + @user = purchase.user + mail(to: @user.email, subject: 'Purchase Confirmation') + end + + def status_update(purchase) + @purchase = purchase + @user = purchase.user + mail(to: @user.email, subject: "Your order status has been updated") + end +end \ No newline at end of file diff --git a/app/models/admin.rb b/app/models/admin.rb index bfe0839..c162f0b 100644 --- a/app/models/admin.rb +++ b/app/models/admin.rb @@ -1,6 +1,10 @@ -require "admin/config" +require "backstage/rails/config" -Admin::Config.configure do + +class Admin +end + +Backstage::Rails::Config.configure do resource :users do column :id column :email @@ -8,10 +12,18 @@ column :role column :editor + scope :all + scope :admins, -> { where(role: 'admin') } + scope :recent, -> { where('created_at > ?', 1.week.ago) } + filter :email_cont, :string, label: 'Email contains' filter :username_cont, :string, label: 'Username contains' filter :role_cont, :select, collection: -> { User.roles.keys } + filterable_field :username, :string + filterable_field :email, :string + filterable_field :role, :select, collection: -> { [:admin, :artist] } + form_field :email, :email form_field :username, :string form_field :role, :select, collection: -> { User.roles.keys }, include_blank: false @@ -41,6 +53,11 @@ column :author do |post, view| view.link_to post.user.full_name, view.admin_user_path(post.user) end + + scope :all + scope :published, -> { where(published: true) } + scope :draft, -> { where(published: false) } + scope :recent, -> { where('created_at > ?', 1.week.ago) } filter :title, :string, label: 'Name contains' @@ -50,4 +67,18 @@ action :edit action :delete end + + resource :terms_and_conditions do + + column :id + column :title + + form_field :title, :string + form_field :category, :string + form_field :content, :custom, ->(view, form) { view.render("shared/simple_editor", form: form, field: :content) } + + action :view + action :edit + action :delete + end end \ No newline at end of file diff --git a/app/models/playlist.rb b/app/models/playlist.rb index d115ba7..0876eca 100644 --- a/app/models/playlist.rb +++ b/app/models/playlist.rb @@ -23,6 +23,7 @@ def self.plain has_many :listening_events has_many :comments, as: :commentable has_many :likes, as: :likeable + has_many :products has_one_attached :cover has_one_attached :zip @@ -41,7 +42,7 @@ def check_label scope :latests, -> { order("id desc") } scope :published, -> { where(private: false) } - + scope :albums, -> { where(playlist_type: "album") } store_accessor :metadata, :buy_link, :string store_accessor :metadata, :buy_link_title, :string store_accessor :metadata, :buy, :boolean diff --git a/app/models/product.rb b/app/models/product.rb new file mode 100644 index 0000000..93830ed --- /dev/null +++ b/app/models/product.rb @@ -0,0 +1,75 @@ +# app/models/product.rb +class Product < ApplicationRecord + extend FriendlyId + friendly_id :title, use: :slugged + acts_as_paranoid + + belongs_to :user + belongs_to :album, class_name: 'Playlist', optional: true, foreign_key: :playlist_id + has_many :product_variants, dependent: :destroy + has_many :product_options, dependent: :destroy + has_many :product_images + + has_many :purchased_items, as: :purchased_item + has_many :product_shippings, dependent: :destroy + has_many :product_purchase_items + has_many :product_purchases, through: :product_purchase_items + + + validates :title, presence: true + validates :description, presence: true + validates :price, presence: true, numericality: { greater_than_or_equal_to: 0 } + validates :stock_quantity, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :sku, presence: true, uniqueness: true + validates :category, presence: true + validates :status, presence: true + validates :album, presence: true, if: -> { ['vinyl', 'cassette'].include?(category) } + + + attribute :limited_edition, :boolean + attribute :limited_edition_count, :integer + attribute :include_digital_album, :boolean + attribute :visibility, :string + attribute :name_your_price, :boolean + attribute :shipping_days, :integer + attribute :shipping_begins_on, :date + attribute :shipping_within_country_price, :decimal + attribute :shipping_worldwide_price, :decimal + attribute :quantity, :integer + + enum status: { active: 'active', inactive: 'inactive', sold_out: 'sold_out' } + enum category: { merch: 'merch', vinyl: 'vinyl', cassette: 'cassette', cd: 'cd', other: 'other' } + + scope :active, -> { where(status: 'active') } + scope :by_category, ->(category) { where(category: category) } + + accepts_nested_attributes_for :product_variants + accepts_nested_attributes_for :product_options + accepts_nested_attributes_for :product_images + accepts_nested_attributes_for :product_shippings, allow_destroy: true, reject_if: :all_blank + + after_create :create_default_shippings + + def self.ransackable_attributes(auth_object = nil) + ["category", "created_at", "description", "id", "id_value", "include_digital_album", "limited_edition", "limited_edition_count", "name_your_price", "playlist_id", "price", "quantity", "shipping_begins_on", "shipping_days", "shipping_within_country_price", "shipping_worldwide_price", "sku", "status", "stock_quantity", "title", "updated_at", "user_id", "visibility"] + end + + def create_default_shippings + product_shippings.create(country: 'Chile', is_default: true) + product_shippings.create(country: 'Rest of World', is_default: true) + end + + def available? + active? && stock_quantity > 0 + end + + def update_stock(quantity) + new_stock = stock_quantity - quantity + update(stock_quantity: new_stock, status: 'sold_out') if new_stock <= 0 + end + + def decrease_quantity(amount) + update(stock_quantity: [stock_quantity - amount, 0].max) + end + +end \ No newline at end of file diff --git a/app/models/product_cart.rb b/app/models/product_cart.rb new file mode 100644 index 0000000..f70f882 --- /dev/null +++ b/app/models/product_cart.rb @@ -0,0 +1,19 @@ +class ProductCart < ApplicationRecord + belongs_to :user, optional: true + has_many :product_cart_items, dependent: :destroy + has_many :products, through: :product_cart_items + + def add_product(product, quantity = 1) + current_item = product_cart_items.find_by(product: product) + if current_item + current_item.quantity += quantity + else + current_item = product_cart_items.build(product: product, quantity: quantity) + end + current_item.save + end + + def total_price + product_cart_items.sum { |item| item.total_price } + end +end \ No newline at end of file diff --git a/app/models/product_cart_item.rb b/app/models/product_cart_item.rb new file mode 100644 index 0000000..b5a512d --- /dev/null +++ b/app/models/product_cart_item.rb @@ -0,0 +1,10 @@ +class ProductCartItem < ApplicationRecord + belongs_to :product_cart + belongs_to :product + + validates :quantity, presence: true, numericality: { greater_than: 0 } + + def total_price + product.price * quantity + end +end \ No newline at end of file diff --git a/app/models/product_image.rb b/app/models/product_image.rb new file mode 100644 index 0000000..63c72f1 --- /dev/null +++ b/app/models/product_image.rb @@ -0,0 +1,5 @@ +class ProductImage < ApplicationRecord + belongs_to :product + has_one_attached :image +end + diff --git a/app/models/product_option.rb b/app/models/product_option.rb new file mode 100644 index 0000000..91eeee8 --- /dev/null +++ b/app/models/product_option.rb @@ -0,0 +1,7 @@ +class ProductOption < ApplicationRecord + belongs_to :product + + validates :name, presence: true + validates :sku, presence: true + validates :quantity, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } +end \ No newline at end of file diff --git a/app/models/product_purchase.rb b/app/models/product_purchase.rb new file mode 100644 index 0000000..b2358c2 --- /dev/null +++ b/app/models/product_purchase.rb @@ -0,0 +1,39 @@ +class ProductPurchase < ApplicationRecord + belongs_to :user + has_many :product_purchase_items, dependent: :destroy + has_many :products, through: :product_purchase_items + + validates :tracking_code, presence: true, if: -> { shipped? || delivered? } + validates :payment_intent_id, presence: true, if: :completed? + + store_accessor :shipping_address, :line1, :line2, :city, :state, :postal_code, :country + + scope :for_seller, ->(user) { + joins(product_purchase_items: :product) + .where(products: { user_id: user.seller_account_ids }) + .distinct + } + + def total_with_shipping + total_amount + shipping_cost + end + + enum status: { + pending: 'pending', + completed: 'completed', + order_placed: 'order_placed', + refunded: 'refunded', + failed: 'failed' + } + + enum shipping_status: { + processing: 'processing', + shipped: 'shipped', + delivered: 'delivered' + } + + def can_refund? + completed? || shipped? || delivered? + end + +end \ No newline at end of file diff --git a/app/models/product_purchase_item.rb b/app/models/product_purchase_item.rb new file mode 100644 index 0000000..8981d9c --- /dev/null +++ b/app/models/product_purchase_item.rb @@ -0,0 +1,8 @@ +class ProductPurchaseItem < ApplicationRecord + belongs_to :product_purchase + belongs_to :product + + def total_price_with_shipping + (price * quantity) + shipping_cost + end +end \ No newline at end of file diff --git a/app/models/product_shipping.rb b/app/models/product_shipping.rb new file mode 100644 index 0000000..918c240 --- /dev/null +++ b/app/models/product_shipping.rb @@ -0,0 +1,10 @@ +class ProductShipping < ApplicationRecord + belongs_to :product + + validates :country, presence: true + validates :base_cost, :additional_cost, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true + validates :country, uniqueness: { scope: :product_id } + + scope :default, -> { where(is_default: true) } + scope :specific, -> { where(is_default: false) } +end diff --git a/app/models/product_variant.rb b/app/models/product_variant.rb new file mode 100644 index 0000000..afb9fe0 --- /dev/null +++ b/app/models/product_variant.rb @@ -0,0 +1,7 @@ +class ProductVariant < ApplicationRecord + belongs_to :product + + validates :name, presence: true + validates :price, presence: true, numericality: { greater_than_or_equal_to: 0 } + validates :stock_quantity, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } +end \ No newline at end of file diff --git a/app/models/terms_and_condition.rb b/app/models/terms_and_condition.rb new file mode 100644 index 0000000..c9a4403 --- /dev/null +++ b/app/models/terms_and_condition.rb @@ -0,0 +1,5 @@ +class TermsAndCondition < ApplicationRecord + validates :title, presence: true + validates :category, presence: true + validates :content, presence: true +end \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb index e0db393..1b35850 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -23,6 +23,9 @@ class User < ApplicationRecord has_many :purchases has_many :comments has_one :podcaster_info + has_many :products + + has_many :product_purchases has_many :connected_accounts, foreign_key: :parent_id @@ -197,11 +200,28 @@ def is_creator? username.present? && role == "artist" || role == "admin" end + def seller_account_ids + if label? + [id] + child_accounts.pluck(:id) + else + [id] + end + end + + def can_sell_products? + seller? || label? || admin? + end + def user_sales_for(kind = "Track") - purchased_items = PurchasedItem.joins( - "INNER JOIN tracks ON purchased_items.purchased_item_id = tracks.id AND purchased_items.purchased_item_type = '#{kind}'" - ) - .where(tracks: {user_id: id}) + if kind == "Product" + ProductPurchase.for_seller(self) + .order(created_at: :desc) + else + purchased_items = PurchasedItem.joins( + "INNER JOIN tracks ON purchased_items.purchased_item_id = tracks.id AND purchased_items.purchased_item_type = '#{kind}'" + ) + .where(tracks: {user_id: id}) + end end @@ -239,6 +259,10 @@ def self.ransackable_attributes(auth_object = nil) ["bio", "city", "confirmation_sent_at", "confirmation_token", "confirmed_at", "country", "created_at", "current_sign_in_at", "current_sign_in_ip", "editor", "email", "encrypted_password", "failed_attempts", "first_name", "id", "id_value", "invitation_accepted_at", "invitation_created_at", "invitation_limit", "invitation_sent_at", "invitation_token", "invitations_count", "invited_by_id", "invited_by_type", "label", "last_name", "last_sign_in_at", "last_sign_in_ip", "locked_at", "notification_settings", "remember_created_at", "reset_password_sent_at", "reset_password_token", "role", "settings", "sign_in_count", "support_link", "unconfirmed_email", "unlock_token", "updated_at", "username"] end + def self.ransackable_associations(auth_object = nil) + ["avatar_attachment", "avatar_blob", "child_accounts", "comments", "connected_accounts", "event_hosts", "events", "hosted_events", "identities", "invitations", "invited_by", "listening_events", "oauth_credentials", "photos", "playlists", "podcaster_info", "posts", "product_purchases", "products", "profile_header_attachment", "profile_header_blob", "purchases", "reposted_tracks", "reposts", "spotlights", "track_comments", "tracks"] + end + # def password_required? # false # end diff --git a/app/views/admin/default--/add_filter.turbo_stream.erb b/app/views/admin/default--/add_filter.turbo_stream.erb new file mode 100644 index 0000000..711a9e0 --- /dev/null +++ b/app/views/admin/default--/add_filter.turbo_stream.erb @@ -0,0 +1,8 @@ + +<%= form_with(model: @filter_form, url: "") do |f| %> + <%= f.fields_for :filter_items, Backstage::Rails::FilterFormItem.new, :child_index => @index do |ff| %> + <%= turbo_stream.append 'filterList' do %> + <%= render 'admin/shared/filter_item', f: ff, resource: @resource, index: ff.index %> + <% end %> + <% end %> +<% end %> \ No newline at end of file diff --git a/app/views/admin/default/edit.html.erb b/app/views/admin/default--/edit.html.erb similarity index 100% rename from app/views/admin/default/edit.html.erb rename to app/views/admin/default--/edit.html.erb diff --git a/app/views/admin/default/index.html.erb b/app/views/admin/default--/index.html.erb similarity index 74% rename from app/views/admin/default/index.html.erb rename to app/views/admin/default--/index.html.erb index 2e2fb6c..9067d89 100644 --- a/app/views/admin/default/index.html.erb +++ b/app/views/admin/default--/index.html.erb @@ -1,8 +1,13 @@ -<% resource = Admin::Config.resources[controller_name.to_sym] %> +<% resource = Backstage::Rails::Config.resources[controller_name.to_sym] %>
+

<%= resource.name.to_s.titleize %>

+ <%= render 'admin/shared/filter_form', resource: resource %> + <%= render 'admin/shared/scopes_menu', resource: resource %> + +
@@ -13,7 +18,7 @@
- <%= render 'admin/shared/filters', resource: resource %> + <% #= render 'admin/shared/filters', resource: resource %>
diff --git a/app/views/admin/default/new.html.erb b/app/views/admin/default--/new.html.erb similarity index 100% rename from app/views/admin/default/new.html.erb rename to app/views/admin/default--/new.html.erb diff --git a/app/views/admin/default/show.html.erb b/app/views/admin/default--/show.html.erb similarity index 92% rename from app/views/admin/default/show.html.erb rename to app/views/admin/default--/show.html.erb index 96d3eea..786f7be 100644 --- a/app/views/admin/default/show.html.erb +++ b/app/views/admin/default--/show.html.erb @@ -1,4 +1,4 @@ -<% resource = Admin::Config.resources[controller_name.to_sym] %> +<% resource = Backstage::Rails::Config.resources[controller_name.to_sym] %>

<%= controller_name.singularize.titleize %> Details

diff --git a/app/views/admin/shared/_actions.erb b/app/views/admin/shared--/_actions.erb similarity index 100% rename from app/views/admin/shared/_actions.erb rename to app/views/admin/shared--/_actions.erb diff --git a/app/views/admin/shared--/_filter_form.html.erb b/app/views/admin/shared--/_filter_form.html.erb new file mode 100644 index 0000000..d3683c7 --- /dev/null +++ b/app/views/admin/shared--/_filter_form.html.erb @@ -0,0 +1,25 @@ +
+ <%= form_with(model: @filter_form, url: url_for(action: 'index'), method: :get, data: { filter_manager_target: 'filterForm', turbo_frame: 'results' }) do |f| %> +
+ + <%= f.fields_for :filter_items do |filter_form| %> + <%= render 'backstage/rails/shared/filter_item', f: filter_form, resource: resource, index: filter_form.index %> + <% end %> + +
+ +
+ +
+ + <% # f.select :filter_combine, [['AND', 'AND'], ['OR', 'OR']], {}, class: "mr-2" %> + <% # f.select :scope, options_for_select(resource.scopes.map { |s| [s[:name].to_s.titleize, s[:name]] }, @filter_form.scope), { include_blank: 'All' }, class: "mr-2" %> + +
+ <%= f.submit 'Apply', class: 'bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded' %> + +
+ <% end %> +
\ No newline at end of file diff --git a/app/views/admin/shared--/_filter_item.html.erb b/app/views/admin/shared--/_filter_item.html.erb new file mode 100644 index 0000000..7e4fdf1 --- /dev/null +++ b/app/views/admin/shared--/_filter_item.html.erb @@ -0,0 +1,27 @@ +
+ <% if index != 0 %> +
+ <%= f.select :condition, [['AND', 'AND'], ['OR', 'OR']], {}, class: "mr-2" %> +
+ <% end %> +
+ + <%= f.select :field, resource.filterable_fields.map { |field| + [field[:options][:label] || field[:name].to_s.titleize, field[:name].to_s ] }, + {}, class: "mr-2" + %> + + <%= f.select :operator, [ + ['Is', 'eq'], + ['Is not', 'not_eq'], + ['Greater than', 'gt'], + ['Less than', 'lt'], + ['Greater than or equal to', 'gteq'], + ['Less than or equal to', 'lteq'], + ['Contains', 'matches'] + ], {}, class: "mr-2" %> + + <%= f.text_field :value, class: "mr-2", label: false %> + +
+
\ No newline at end of file diff --git a/app/views/admin/shared/_filters.erb b/app/views/admin/shared--/_filters.erb similarity index 95% rename from app/views/admin/shared/_filters.erb rename to app/views/admin/shared--/_filters.erb index ff4db4d..b520111 100644 --- a/app/views/admin/shared/_filters.erb +++ b/app/views/admin/shared--/_filters.erb @@ -1,7 +1,7 @@ <%= search_form_for @q, url: url_for(action: 'index'), class: "mb-6" do |f| %>
<% resource.filters.each do |filter| %> -
+
<%= f.label filter[:name], filter[:options][:label], class: "block text-gray-700 text-sm font-bold mb-2" %> <% case filter[:type] %> <% when :string %> diff --git a/app/views/admin/shared/_form.erb b/app/views/admin/shared--/_form.erb similarity index 100% rename from app/views/admin/shared/_form.erb rename to app/views/admin/shared--/_form.erb diff --git a/app/views/admin/shared--/_scopes_menu.html.erb b/app/views/admin/shared--/_scopes_menu.html.erb new file mode 100644 index 0000000..f07e881 --- /dev/null +++ b/app/views/admin/shared--/_scopes_menu.html.erb @@ -0,0 +1,11 @@ +<% if resource.scopes.any? %> +
+ +
+<% end %> \ No newline at end of file diff --git a/app/views/admin/shared/_table.erb b/app/views/admin/shared--/_table.erb similarity index 87% rename from app/views/admin/shared/_table.erb rename to app/views/admin/shared--/_table.erb index eae9ccc..a8c92f3 100644 --- a/app/views/admin/shared/_table.erb +++ b/app/views/admin/shared--/_table.erb @@ -3,7 +3,7 @@ <% resource.columns.each do |column| %> - <%= sort_link(@q, column[:name], column[:options][:label] || column[:name].to_s.titleize, { class: "text-gray-600 hover:text-gray-900" }) %> + <% # sort_link(@q, column[:name], column[:options][:label] || column[:name].to_s.titleize, { class: "text-gray-600 hover:text-gray-900" }) %> <% end %> diff --git a/app/views/articles/_article_block.html.erb b/app/views/articles/_article_block.html.erb index 13fefcd..91eedc5 100644 --- a/app/views/articles/_article_block.html.erb +++ b/app/views/articles/_article_block.html.erb @@ -13,7 +13,7 @@ <% end %> - <%= t("articles.by") %> <%= post.user.username %> · <%= l post.created_at, format: :short %>. + <%= t("articles.by") %> <%= post.user.username %> · <%= l post.created_at, format: :long_with_day %>.

diff --git a/app/views/articles/_article_item.html.erb b/app/views/articles/_article_item.html.erb index 3a4f35c..4872466 100644 --- a/app/views/articles/_article_item.html.erb +++ b/app/views/articles/_article_item.html.erb @@ -45,7 +45,7 @@ <% end %>

\ No newline at end of file diff --git a/app/views/devise/passwords/edit.html.erb b/app/views/devise/passwords/edit.html.erb index 41babe5..0fc7084 100644 --- a/app/views/devise/passwords/edit.html.erb +++ b/app/views/devise/passwords/edit.html.erb @@ -1,34 +1,31 @@
+
-
+

Change your password

-

Change your password

+ <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { + method: :put, class: "space-y-2 flex flex-col" }) do |f| %> + <%= render "devise/shared/error_messages", resource: resource %> - <%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { - method: :put, class: "space-y-2 flex flex-col" }) do |f| %> - <%= render "devise/shared/error_messages", resource: resource %> - <%= f.hidden_field :reset_password_token %> +
+ <%= f.label :password, "New password" %>
+ <% if @minimum_password_length %> + (<%= @minimum_password_length %> characters minimum)
+ <% end %> + <%= f.password_field :password, autofocus: true, autocomplete: "new-password" %> +
-
- <%= f.label :password, "New password" %>
- <% if @minimum_password_length %> - (<%= @minimum_password_length %> characters minimum)
- <% end %> - <%= f.password_field :password, autofocus: true, autocomplete: "new-password" %> -
+
+ <%= f.password_field :password_confirmation, autocomplete: "new-password" %> +
-
- <%= f.label :password_confirmation, "Confirm new password" %>
- <%= f.password_field :password_confirmation, autocomplete: "new-password" %> -
+
+ <%= f.submit "Change my password" %> +
+ <% end %> -
- <%= f.submit "Change my password" %> -
- <% end %> + <%= render "devise/shared/links" %> - <%= render "devise/shared/links" %> - -
+
\ No newline at end of file diff --git a/app/views/devise/passwords/new.html.erb b/app/views/devise/passwords/new.html.erb index 5a016a0..316d89f 100644 --- a/app/views/devise/passwords/new.html.erb +++ b/app/views/devise/passwords/new.html.erb @@ -5,9 +5,9 @@

Forgot your password?

<%= form_for(resource, - as: resource_name, - url: password_path(resource_name), - html: { method: :post, class: "space-y-2 flex flex-col" }) do |f| %> + as: resource_name, + url: password_path(resource_name), + html: { method: :post, class: "space-y-2 flex flex-col" }) do |f| %> <%= render "devise/shared/error_messages", resource: resource %>
diff --git a/app/views/event_streaming_services/show.html.erb b/app/views/event_streaming_services/show.html.erb index fbd7bf8..14418d5 100644 --- a/app/views/event_streaming_services/show.html.erb +++ b/app/views/event_streaming_services/show.html.erb @@ -45,8 +45,10 @@ - <%= render @provider, event: @event %> - + <% if @provider.present? %> + <%= render @provider, event: @event %> + <% else %> +
- <%= gettext("no renderer avaiable") %> + <%= gettext("no renderer available") %>
+ <% end %> +
diff --git a/app/views/events/_form.html.erb b/app/views/events/_form.html.erb index d0862f7..293f17e 100644 --- a/app/views/events/_form.html.erb +++ b/app/views/events/_form.html.erb @@ -1,240 +1,225 @@ -
-

- <% if @event.id %> - <%= t("events.edit") %> - <% else %> - <%= t("events.create") %> - <% end %> -

- - - <%= render "status_button" if @event.persisted? %> - - - - <%= form_for @event , data: { - controller: "gmaps", - action: "google-maps-callback@window->gmaps#initializeMap" - } do |f| %> -
- -
- <%= f.text_field :title %> -
- -
- <%= f.time_zone_select :timezone, [] , label: gettext("Event Timezone") %> -
- -
- <%= f.datetime_field :event_start %> -
- -
- <%= f.datetime_field :event_ends %> -
+
+

+ <% if @event.id %> + <%= t("events.edit") %> + <% else %> + <%= t("events.create") %> + <% end %> +

+ + <%= render "status_button" if @event.persisted? %> + +
+ + <%= form_for @event , data: { + controller: "gmaps", + action: "google-maps-callback@window->gmaps#initializeMap" + } do |f| %> +
+ +
+ <%= f.text_field :title %> +
-
- - <%= f.file_field :cover, class: "block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400" %> -

SVG, PNG, JPG or GIF (MAX. 800x400px).

-
+
+ <%= f.time_zone_select :timezone, [] , label: gettext("Event Timezone") %> +
+
+ <%= f.datetime_field :event_start %> +
+ +
+ <%= f.datetime_field :event_ends %> +
-
- <%= gettext("Times are displayed in") %> - .... -
+
+ + <%= f.file_field :cover, class: "block w-full text-sm text-gray-900 border border-gray-300 rounded-lg cursor-pointer bg-gray-50 dark:text-gray-400 focus:outline-none dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400" %> +

SVG, PNG, JPG or GIF (MAX. 800x400px).

+
-
- <%= f.text_area :description %> -
-
-
- -
+
+ <%= gettext("Times are displayed in") %> + .... +
-
- <%= f.radio_button( :private, true, - class: "focus:ring-brand-500 h-4 w-4 text-brand-600 border-gray-300" - ) %> - -
+
+ <%= f.text_area :description %> +
-
- <%= f.radio_button(:private, false, - class: "focus:ring-brand-500 h-4 w-4 text-brand-600 border-gray-300" - ) %> - -
+
+
+
-
- <%= f.text_field :venue %> - <%= f.select :age_requirement, [ - ["All ages", "all"], - ["13+", "13"], - ["16+", "16"], - ["17+", "17"], - ["18+", "18"], - ["19+", "19"], - ["20+", "20"], - ["21+", "21"] - ] %> +
+ <%= f.radio_button( :private, true, + class: "focus:ring-brand-500 h-4 w-4 text-brand-600 border-gray-300", + label: "Private" + ) %>
-
-
-
- <%= f.label :payment_gateway %> - <%= f.select( :payment_gateway, [ - ["none", "none"], - ["stripe", "stripe", {disabled: !@event.has_stripe?} ], - ["transbank", "transbank", {disabled: !@event.has_transbank?} ] - ], - hint: gettext("para transbank usaremos tu codigo de comercio de los ajustes") - ) %> - -
-
- - <%= f.label :ticket_currency %> - <%= f.select( :ticket_currency, [ - ["CLP", "clp"], - ["USD", "usd"], - ["EUR", "eur"] - ], - hint: gettext("Choose the currency in which you will charge") - ) %> -
-
+
+ <%= f.radio_button(:private, false, + class: "focus:ring-brand-500 h-4 w-4 text-brand-600 border-gray-300", + label: "Public" + ) %>
+
-
- uploads here -
+
+ <%= f.text_field :venue %> + <%= f.select :age_requirement, [ + ["All ages", "all"], + ["13+", "13"], + ["16+", "16"], + ["17+", "17"], + ["18+", "18"], + ["19+", "19"], + ["20+", "20"], + ["21+", "21"] + ] %>
-
- <% # = form_input_renderer(f, %{type: :text_input, name: :location, wrapper_class: nil}) %> - -
- <%= f.label :location, - class: "block text-sm font-medium text-gray-700 dark:text-gray-300" - %> -
- <%= f.text_field(:location, - placeholder: "type location", - "data-gmaps-target": "field", - autocomplete: "false", - "data-action": "keydown->maps#preventSubmit", - class: "shadow-sm focus:ring-brand-500 focus:border-brand-500 block w-full sm:text-sm border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-900 dark:bg-gray-900 dark:text-gray-100" +
+
+
+ <%= f.label :payment_gateway %> + <%= f.select( :payment_gateway, [ + ["none", "none"], + ["stripe", "stripe", {disabled: !@event.has_stripe?} ], + ["transbank", "transbank", {disabled: !@event.has_transbank?} ] + ], + hint: gettext("para transbank usaremos tu codigo de comercio de los ajustes") + ) %> + +
+
+ + <%= f.label :ticket_currency %> + <%= f.select( :ticket_currency, [ + ["CLP", "clp"], + ["USD", "usd"], + ["EUR", "eur"] + ], + hint: gettext("Choose the currency in which you will charge") ) %>
-

- <%= gettext("Type the event address, and confirm the prompt") %> -

+
- <%= f.text_field(:lat, - "data-gmaps-target": "latitude", - autocomplete: "false", - "data-action": "keydown->maps#preventSubmit" - ) %> - - <%= f.text_field(:lng, - "data-gmaps-target": "longitude", - autocomplete: "false", - "data-action": "keydown->maps#preventSubmit" - ) %> - - <%= f.text_field(:country, - "data-gmaps-target": "country", - autocomplete: "false", - "data-action": "keydown->maps#preventSubmit", - class: "shadow-sm focus:ring-brand-500 focus:border-brand-500 block w-full sm:text-sm border-gray-300 rounded-md dark:bg-gray-900 dark:bg-gray-900 dark:text-gray-100" - ) %> - - <%= f.text_field( :city, - "data-gmaps-target": "city", - autocomplete: "false", - "data-action": "keydown->maps#preventSubmit", - class: "shadow-sm focus:ring-brand-500 focus:border-brand-500 block w-full sm:text-sm border-gray-300 rounded-md dark:bg-gray-900 dark:bg-gray-900 dark:text-gray-100" - ) %> - - <%= f.text_field(:province, - "data-gmaps-target": "province", - autocomplete: "false", - "data-action": "keydown->maps#preventSubmit", - class: "shadow-sm focus:ring-brand-500 focus:border-brand-500 block w-full sm:text-sm border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-900 dark:bg-gray-900 dark:text-gray-100" - ) %> - -
-
+
+ +
+
+ +
+ <% # = form_input_renderer(f, %{type: :text_input, name: :location, wrapper_class: nil}) %> + +
+
+ <%= f.text_field(:location, + placeholder: "type location", + "data-gmaps-target": "field", + autocomplete: "false", + "data-action": "keydown->maps#preventSubmit", + class: "shadow-sm focus:ring-brand-500 focus:border-brand-500 block w-full sm:text-sm border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-900 dark:bg-gray-900 dark:text-gray-100" + ) %>
+

+ <%= gettext("Type the event address, and confirm the prompt") %> +

-
-
-
- <%= f.text_field :participant_label %> -
- -
- <%= f.text_area :participant_description %> -
+ <%= f.hidden_field(:lat, + "data-gmaps-target": "latitude", + autocomplete: "false", + "data-action": "keydown->maps#preventSubmit" + ) %> + + <%= f.hidden_field(:lng, + "data-gmaps-target": "longitude", + autocomplete: "false", + "data-action": "keydown->maps#preventSubmit" + ) %> + + <%= f.hidden_field(:country, + "data-gmaps-target": "country", + autocomplete: "false", + "data-action": "keydown->maps#preventSubmit", + class: "shadow-sm focus:ring-brand-500 focus:border-brand-500 block w-full sm:text-sm border-gray-300 rounded-md dark:bg-gray-900 dark:bg-gray-900 dark:text-gray-100" + ) %> + + <%= f.hidden_field( :city, + "data-gmaps-target": "city", + autocomplete: "false", + "data-action": "keydown->maps#preventSubmit", + class: "shadow-sm focus:ring-brand-500 focus:border-brand-500 block w-full sm:text-sm border-gray-300 rounded-md dark:bg-gray-900 dark:bg-gray-900 dark:text-gray-100" + ) %> + + <%= f.hidden_field(:province, + "data-gmaps-target": "province", + autocomplete: "false", + "data-action": "keydown->maps#preventSubmit", + class: "shadow-sm focus:ring-brand-500 focus:border-brand-500 block w-full sm:text-sm border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-900 dark:bg-gray-900 dark:text-gray-100" + ) %> + +
+
+
+
+
+
+
+ <%= f.text_field :participant_label %>
- + <% end %> + +
+
\ No newline at end of file diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 79f03c1..46a49cf 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -23,7 +23,7 @@ 📊 Dashboard <% end %> - <% Admin::Config.resources.each do |k ,v| %> + <% Backstage::Rails::Config.resources.each do |k ,v| %> <%= link_to [:admin, k], class: "block py-2 px-4 hover:bg-gray-700" do %> <%= k %> <% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index e0f8193..ead1eaf 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -26,7 +26,7 @@ - <%= render "shared/user_menu" %> diff --git a/app/views/playlists/_products.erb b/app/views/playlists/_products.erb new file mode 100644 index 0000000..d6d06c1 --- /dev/null +++ b/app/views/playlists/_products.erb @@ -0,0 +1,47 @@ +<% if @playlist.products.any? %> +
+

Available Products

+
+ <% @playlist.products.each do |product| %> +
+ + <% if product.product_images.any? %> + <% product.product_images.each do |pi| %> + <%= image_tag pi.image, class: "w-full h-48 object-cover" %> + <% end %> + <% else %> +
+ No image available +
+ <% end %> + +
+ + <%= link_to product.category, user_products_path(product.user.username), + class: "bg-muted text-sm inline-flex hover:cursor-pointer items-center rounded-full hover:bg-emphasis px-3 py-1 font-medium" + %> +

+ <%= link_to product.title, user_product_path(product.user.username, product), class: "text-link" %> +

+

+ <%= truncate( strip_tags(product.description), length: 180 ) %> +

+

+ <%= number_to_currency(product.price) %> + <% if product.name_your_price %> + (or more) + <% end %> +

+ + <% if product.stock_quantity.to_i > 0 %> + <%= button_to "Add to Cart", product_cart_add_path(product_id: product.id), method: :post, class: "w-full bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded" %> + <% else %> + + <% end %> + +
+
+ <% end %> +
+
+<% end %> \ No newline at end of file diff --git a/app/views/playlists/show.html.erb b/app/views/playlists/show.html.erb index aeecbad..35067eb 100644 --- a/app/views/playlists/show.html.erb +++ b/app/views/playlists/show.html.erb @@ -145,10 +145,8 @@
- - -
+

@@ -165,7 +163,6 @@ <%= render "likes/like_button", resource: @playlist, button_class: liked?(@playlist) ? "button-active" : "button" %> - <% if user_signed_in? && @playlist.user_id == current_user.id %> <%= link_to edit_playlist_path(@playlist), class: "button", "data-turbo-frame": "modal" do %> @@ -175,7 +172,6 @@ <%= t("playlists.edit") %> <% end %> -
<%= turbo_frame_tag "playlist-#{@playlist.id}-delete" do %> <%= button_to playlist_path(@playlist), @@ -191,8 +187,6 @@
<% end %> - -

@@ -203,7 +197,7 @@
- - - <% if @playlist.buy_link.present? %> -
-
- <%= link_to @playlist.buy_link_title || "Payment Link", - @playlist.buy_link, - target: :blank, - class: "underline dark:border-white hover:bg-white hover:text-black border-black rounded-sm border-4 px-3 py-3" %> + <% if @playlist.buy_link.present? %> +
+
+ <%= link_to @playlist.buy_link_title || "Payment Link", + @playlist.buy_link, + target: :blank, + class: "underline dark:border-white hover:bg-white hover:text-black border-black rounded-sm border-4 px-3 py-3" %> +
-
- <% end %> + <% end %> + - +
+ <%= render "products" %> +
+
<%= auto_link sanitize(@playlist.description, auto_link: true) %> @@ -297,8 +294,6 @@
- -
diff --git a/app/views/product_cart/show.erb b/app/views/product_cart/show.erb new file mode 100644 index 0000000..d1a9ae2 --- /dev/null +++ b/app/views/product_cart/show.erb @@ -0,0 +1,30 @@ + +
+

Your Cart

+ + <% if @cart.product_cart_items.any? %> +
+ <% @cart.product_cart_items.each do |item| %> +
+
+

+ <%= link_to item.product.title, user_product_path(item.product.user.username, item.product), class: "text-link" %> +

+

Quantity: <%= item.quantity %>

+

Price: <%= number_to_currency(item.product.price) %>

+
+ <%= button_to "Remove", product_cart_remove_path(product_id: item.product.id), method: :delete, data: {turbo_method: :delete, turbo_confirm: "delete item?", turbo_frame: "_top"}, class: "bg-red-500 text-default px-4 py-2 rounded" %> +
+ <% end %> +
+ +
+

Total: <%= number_to_currency(@cart.total_price) %>

+ <%= form_tag product_checkout_index_path, method: :post, data: {turbo: false } do %> + <%= submit_tag "Proceed to Checkout", class: "mt-4 bg-muted text-default px-6 py-3 rounded-lg font-bold cursor-pointer" %> + <% end %> +
+ <% else %> +

Your cart is empty.

+ <% end %> +
\ No newline at end of file diff --git a/app/views/product_purchase_mailer/purchase_confirmation.html.erb b/app/views/product_purchase_mailer/purchase_confirmation.html.erb new file mode 100644 index 0000000..a35e9ae --- /dev/null +++ b/app/views/product_purchase_mailer/purchase_confirmation.html.erb @@ -0,0 +1,9 @@ +

Thank you for your purchase, <%= @user.name %>!

+ +

Your order details:

+ +<% @purchase.product_purchase_items.each do |item| %> +

<%= item.product.title %> - Quantity: <%= item.quantity %> - Price: <%= number_to_currency(item.price) %>

+<% end %> + +

Total: <%= number_to_currency(@purchase.total_amount) %>

\ No newline at end of file diff --git a/app/views/product_purchase_mailer/status_update.html.erb b/app/views/product_purchase_mailer/status_update.html.erb new file mode 100644 index 0000000..101ee3e --- /dev/null +++ b/app/views/product_purchase_mailer/status_update.html.erb @@ -0,0 +1,12 @@ + +

Your Order Status Has Been Updated

+ +

Dear <%= @user.full_name %>,

+ +

The status of your order #<%= @purchase.id %> has been updated to: <%= @purchase.status.titleize %>

+ +<% if @purchase.tracking_code.present? %> +

Tracking Code: <%= @purchase.tracking_code %>

+<% end %> + +

Thank you for your purchase!

\ No newline at end of file diff --git a/app/views/product_purchases/index.html.erb b/app/views/product_purchases/index.html.erb new file mode 100644 index 0000000..b7e2474 --- /dev/null +++ b/app/views/product_purchases/index.html.erb @@ -0,0 +1,209 @@ +<% @purchases.each do |purchase| %> + +
+
+
+ +
+

+ Order #<%= purchase.id %> +

+ +
+ +

Order placed + +

+ + + View invoice + + +
+ + +
+

Products purchased

+ +
+
+
+
+ + <% purchase.product_purchase_items.each do |item| %> + + <% if item.product.product_images&.first&.image&.present? %> +
+ <%= image_tag item.product.product_images.first.image, class: "h-full w-full object-cover object-center sm:h-full sm:w-full" %> +
+ <% end %> + +
+

+ <%= link_to item&.product&.title, user_product_path(item.product&.user&.username, item&.product ) %> +

+

+ Price: <%= number_to_currency(item.price) %> +

+

+ Shipping cost: <%= number_to_currency(item.shipping_cost) %> +

+
+ + <% end %> + +
+ +
+
+
+
Delivery address
+
+ + <%= purchase.line1 %> + <%= purchase.line2 %> <%= purchase.postal_code %> + <%= purchase.city %>, <%= purchase.state %>, <%= purchase.country %> +
+
+ + <% if purchase.tracking_code %> +

Tracking code: <%= purchase.tracking_code %>

+ <% end %> + + + +
+
+
+ +
+

Status

+ + + + + + + + +
+
+
+
+ + +
+

Billing Summary

+ +
+ + +
+
+
Subtotal
+
+ <%= number_to_currency(purchase.total_amount) %> +
+
+ + +
+
Order total
+
+ <%= number_to_currency(purchase.total_amount) %> +
+
+ +
+
Status
+
+ <%= purchase.status.capitalize %> +
+
+ + <%= link_to "View Details", product_purchase_path(purchase) %> + + +
+
+
+ + +
+
+ +<% end %> diff --git a/app/views/product_purchases/show.html.erb b/app/views/product_purchases/show.html.erb new file mode 100644 index 0000000..3b4d4e8 --- /dev/null +++ b/app/views/product_purchases/show.html.erb @@ -0,0 +1,15 @@ +

Purchase #<%= @purchase.id %>

+ +

Date: <%= l purchase.created_at, format: :long %>

+

Status: <%= @purchase.status.capitalize %>

+ +

Items:

+<% @purchase.product_purchase_items.each do |item| %> +
+

<%= item.product.title %>

+

Quantity: <%= item.quantity %>

+

Price: <%= number_to_currency(item.price) %>

+
+<% end %> + +

Total: <%= number_to_currency(@purchase.total_amount) %>

\ No newline at end of file diff --git a/app/views/product_variants/_variant_fields.erb b/app/views/product_variants/_variant_fields.erb new file mode 100644 index 0000000..f616de1 --- /dev/null +++ b/app/views/product_variants/_variant_fields.erb @@ -0,0 +1,17 @@ +
+ +
+ <%= form.text_field :name %> + <%= form.number_field :price, step: 0.01 %> + <%= form.number_field :stock_quantity %> + <%= form.hidden_field :_destroy %> +
+ + +
+ <%= link_to "Remove", "#", + class: "inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500", + data: { action: "click->nested-form#remove_association" } %> +
+
\ No newline at end of file diff --git a/app/views/product_variants/create.html.erb b/app/views/product_variants/create.html.erb new file mode 100644 index 0000000..ebc2fc3 --- /dev/null +++ b/app/views/product_variants/create.html.erb @@ -0,0 +1,2 @@ +

ProductVariants#create

+

Find me in app/views/product_variants/create.html.erb

diff --git a/app/views/product_variants/destroy.html.erb b/app/views/product_variants/destroy.html.erb new file mode 100644 index 0000000..72c3785 --- /dev/null +++ b/app/views/product_variants/destroy.html.erb @@ -0,0 +1,2 @@ +

ProductVariants#destroy

+

Find me in app/views/product_variants/destroy.html.erb

diff --git a/app/views/product_variants/update.html.erb b/app/views/product_variants/update.html.erb new file mode 100644 index 0000000..14affe0 --- /dev/null +++ b/app/views/product_variants/update.html.erb @@ -0,0 +1,2 @@ +

ProductVariants#update

+

Find me in app/views/product_variants/update.html.erb

diff --git a/app/views/products/_form.erb b/app/views/products/_form.erb new file mode 100644 index 0000000..0da4857 --- /dev/null +++ b/app/views/products/_form.erb @@ -0,0 +1,155 @@ + +
+ +

+ <% if product.new_record? %> + New Product + <% else %> + Edit product + <% end %> +

+ + <%= form_with(model: product, local: true) do |form| %> + + <%= form.object.errors.full_messages.join(" ") %> + +
+ +
+ <%= form.select :category, Product.categories.keys %> + <% if ['vinyl', 'cassette'].include?(product.category) %> + <%= form.select :playlist_id, current_user.playlists.where(playlist_type: ['album', 'ep']).map{|o| + [o.title, o.id] + }, label: "Album" %> + <% end %> +
+ + <%= form.text_field :title %> + + <%= render "shared/simple_editor", form: form, field: :description %> + + + <%= form.number_field :price, step: 0.01 %> + +
+ <%= form.check_box :name_your_price, class: "rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" %> + Let fans pay more if they want +
+ +
+ <%= form.number_field :stock_quantity %> + <%= form.text_field :sku %> +
+ +

+ We automatically decrement the quantity for you as the item sells — you only need to edit it if you’re also selling elsewhere (like at your shows, or another website). + Leave quantity blank for unlimited. +

+ + <% # form.number_field :quantity, label: "Quantity remaining", class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" %> + + <%= form.select :status, Product.statuses.keys %> + +
+ <%= form.check_box :limited_edition, class: "rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" %> + Show limited edition indicator to fans +
+ +
+ <%= form.number_field :limited_edition_count, label: "Edition of", class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" %> +

will appear just beneath the item description

+
+ +
+ <%= form.check_box :include_digital_album, class: "rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" %> + Include digital album or track with the purchase of this item +
+ +
+ <%= form.select :visibility, options_for_select([['Public', 'public'], ['Private', 'private']], @product.visibility), {}, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" %> +
+ +
+ + +
+ <%= form.fields_for :product_images do |ff| %> + <%= render 'product_images', form: ff %> + <% end %> +
+ +
+ <%= link_to "Add new photo", "#", + class: "inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500", + data: { action: "click->nested-form#add_association" } %> +
+
+ +
+ + +
+ + <% # render "variant_form_section", form: form %> + +
+

Shipping

+ +
+ <%= form.label :shipping_days, "Orders ship out within" %> + <%= form.number_field :shipping_days %> + days of being placed +
+ +
+ + +
+ <%= form.number_field :shipping_days, label: "Orders ship out within", class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" %> + days of being placed +
+ +
+ <%= form.date_field :shipping_begins_on, label: "Begins shipping on", class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" %> +
+ + + +
+ <%= form.fields_for :product_shippings do |shipping_form| %> + <%= render "shipping_fields", form: shipping_form %> + <% end %> +
+ +
+ <%= link_to "Add new shipping options", "#", + class: "inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500", + data: { action: "click->nested-form#add_association" } %> +
+
+
+ + +
+ <%= form.submit %> +
+
+ + <% end %> +
\ No newline at end of file diff --git a/app/views/products/_gallery_photo.erb b/app/views/products/_gallery_photo.erb new file mode 100644 index 0000000..6e6e6aa --- /dev/null +++ b/app/views/products/_gallery_photo.erb @@ -0,0 +1,6 @@ +<%= turbo_frame_tag "gallery-photo" do %> +
+ <%= image_tag image, + class: "h-full w-full object-cover object-center sm:rounded-lg" %> +
+<% end %> \ No newline at end of file diff --git a/app/views/products/_product.erb b/app/views/products/_product.erb new file mode 100644 index 0000000..7d3127f --- /dev/null +++ b/app/views/products/_product.erb @@ -0,0 +1,32 @@ +
+
+
+ <% if product.product_images.any? %> + <%= image_tag product.product_images.first.image, class: "h-full w-full object-cover object-center" %> + <% else %> + [no image] + <% end %> +
+
+

+ <%= link_to product.title, user_product_path(@profile.username, product) %> +

+

+ <%= truncate( strip_tags( product.description), length: 200) %> +

+

+ <%= product.category.humanize %> +

+
+
+ +

+ <%= number_to_currency(product.price) %> +

+
+
+
+ <%= button_to "Add to cart", product_cart_add_path(product_id: product.id), method: :post, class: "button-sm-outline w-full text-center justify-center" %> +
+
\ No newline at end of file diff --git a/app/views/products/_product_images.erb b/app/views/products/_product_images.erb new file mode 100644 index 0000000..bf43ca6 --- /dev/null +++ b/app/views/products/_product_images.erb @@ -0,0 +1,26 @@ +
+ +
+ +
+ <% if form.object.image.present? %> + <%= image_tag form.object.image, width: 200 %> + <% end %> +
+ +
+ <%= form.file_field :image %> + <%= form.text_field :title %> + <%= form.text_area :description %> + <%= form.hidden_field :_destroy %> +
+ +
+ +
+ <%= link_to "Remove", "#", + class: "inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500", + data: { action: "click->nested-form#remove_association" } %> +
+
\ No newline at end of file diff --git a/app/views/products/_product_option_fields.erb b/app/views/products/_product_option_fields.erb new file mode 100644 index 0000000..1e50327 --- /dev/null +++ b/app/views/products/_product_option_fields.erb @@ -0,0 +1,14 @@ +
+
+
+ <%= f.text_field :name, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" %> +
+
+ <%= f.text_field :quantity, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" %> +
+
+ <%= f.text_field :sku, class: "mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" %> +
+
+ <% # link_to_remove_association "Remove option", f, class: "mt-2 text-sm text-red-600 hover:text-red-900" %> +
\ No newline at end of file diff --git a/app/views/products/_shipping_fields.erb b/app/views/products/_shipping_fields.erb new file mode 100644 index 0000000..e296c0c --- /dev/null +++ b/app/views/products/_shipping_fields.erb @@ -0,0 +1,28 @@ +
+ +
+
+ <%= form.label :country %> + <%= form.country_select :country, + disabled: form.object.is_default?, + priority_countries: ["CL", "AR", "PE", "BO", "CO", "MX", "US", "BR", "ES", "FR", "GR"], + except: %w[AS CX CC CU HM IR KP MH FM NF MP PW SD SY UM VI] + %> +
+ + <%= form.number_field :base_cost, step: 0.01, label: "USD for 1 unit" %> + + <%= form.number_field :additional_cost, step: 0.01, hint: "USD more per additional unit" %> +
+ + <% unless form.object.is_default? %> + <%= form.hidden_field :_destroy %> + <% end %> + +
+ <%= link_to "Remove", "#", + class: "inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500", + data: { action: "click->nested-form#remove_association" } %> +
+
\ No newline at end of file diff --git a/app/views/products/_variant_form_section.erb b/app/views/products/_variant_form_section.erb new file mode 100644 index 0000000..83225ed --- /dev/null +++ b/app/views/products/_variant_form_section.erb @@ -0,0 +1,35 @@ +
+

Variants

+ +
+ + +
+ <%= form.fields_for :product_variants do |ff| %> + <%= render 'product_variants/variant_fields', form: ff %> + <% end %> +
+ +
+ <%= link_to "Add new variant", "#", + class: "inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-full shadow-sm text-white bg-brand-600 hover:bg-brand-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-500", + data: { action: "click->nested-form#add_association" } %> +
+
+ + + +
\ No newline at end of file diff --git a/app/views/products/_variants_info.erb b/app/views/products/_variants_info.erb new file mode 100644 index 0000000..212f44f --- /dev/null +++ b/app/views/products/_variants_info.erb @@ -0,0 +1,115 @@ +
+

Color

+ +
+
+ + + + + + +
+
+
+ +
+
+

Size

+ Size guide +
+ +
+
+ + + + + + + + + + + + + + + + +
+
+
\ No newline at end of file diff --git a/app/views/products/edit.html.erb b/app/views/products/edit.html.erb new file mode 100644 index 0000000..cb5b9b9 --- /dev/null +++ b/app/views/products/edit.html.erb @@ -0,0 +1 @@ +<%= render "form", product: @product %> \ No newline at end of file diff --git a/app/views/products/index.html.erb b/app/views/products/index.html.erb new file mode 100644 index 0000000..d4cae2d --- /dev/null +++ b/app/views/products/index.html.erb @@ -0,0 +1,36 @@ + + +
+
+

+ <%= link_to user_path(@profile.username), class: "text-brand-600 flex items-center space-x-2" do %> + <%= heroicon("chevron-left") %> + <%= @profile.username %> + <% end %> + Products & Merch +

+ + +
+ <%= search_form_for @q, url: user_products_path(@profile.username), class: "flex items-center space-x-4" do |f| %> +
+ <%= f.search_field :title_cont, placeholder: "Search products...", class: "w-full px-4 py-2 rounded-md border-muted shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 bg-emphasis" %> +
+
+ <%= f.select :category_eq, options_for_select(Product.categories.keys.map { |c| [c.titleize, c] }, @q.category_eq), { include_blank: "All Categories" }, class: "block w-full pl-3 pr-10 py-2 text-base border-muted focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md" %> +
+ <%= f.submit "Search", class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> + <% end %> +
+ +
+ <% @products.each do |product| %> + <%= render "product", product: product %> + <% end %> +
+ + <% if @products.blank? %> +

No Products found

+ <% end %> +
+
\ No newline at end of file diff --git a/app/views/products/new.html.erb b/app/views/products/new.html.erb new file mode 100644 index 0000000..cb5b9b9 --- /dev/null +++ b/app/views/products/new.html.erb @@ -0,0 +1 @@ +<%= render "form", product: @product %> \ No newline at end of file diff --git a/app/views/products/show.html.erb b/app/views/products/show.html.erb new file mode 100644 index 0000000..cd35a80 --- /dev/null +++ b/app/views/products/show.html.erb @@ -0,0 +1,372 @@ +
+ +
+ + + +
+
+

+ <%= @product.title %> +

+
+ + +
+

Product information

+

+ <%= number_to_currency(@product.price) %> +

+ + +
+ + + +
+
+
+
SKU
+
<%= @product.sku %>
+
+
+
Category
+
<%= @product.category.titleize %>
+
+
+
Status
+
<%= @product.status.titleize %>
+
+
+
Stock Quantity
+
<%= @product.stock_quantity %>
+
+
+
Seller
+
+ <%= link_to @product.user.username, user_path(@product.user.username), class: "text-brand-600 hover:text-brand-900" %> +
+
+ <% if @product.album.present? %> +
+
Associated Album
+
+ <%= link_to @product.album.title, playlist_path(@product.album), class: "text-brand-600 hover:text-brand-900" %> +
+
+ <% end %> +
+
+ +
+ +
+ + <% render "variants_info" %> + +
+ <% if @product.available? %> + <%= button_to "Add to Cart", "#", method: :post, class: "mt-10 flex w-full items-center justify-center rounded-md border border-transparent bg-indigo-600 px-8 py-3 text-base font-medium text-default hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2" %> + <% else %> +

Sold Out

+ <% end %> + +
+ <% if current_user == @product.user || current_user&.is_admin? %> + <%= link_to "Edit Product", edit_product_path(@product), class: "btn-outlined" %> + <%= button_to "Delete Product", product_path(@product), method: :delete, data: { confirm: "Are you sure you want to delete this product?" }, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-default bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %> + <% end %> +
+
+ +
+ <%= link_to "Back to Products", user_products_path(@product.user.username), class: "text-brand-600 hover:text-brand-900" %> +
+ +
+
+ +
+ +
+ + + +
+ + + + + <%= render "gallery_photo", image: @product.product_images.first.image %> +
+ +

Description

+ +
+

+ <%= raw sanitize(@product.description) %> +

+
+
+ + +
+ +<% +=begin %> +
+ +
+

Reviews

+ +
+
+
+

Adds the perfect variety to my wardrobe

+

4 out of 5 stars

+ +
+

I used to be one of those unbearable minimalists who only wore the same black v-necks every day. Now, I have expanded my wardrobe with three new crewneck options! Leaving off one star only because I wish the heather gray was more gray.

+
+
+ +
+ Blake Reid. + +
+

Blake Reid

+
+ + + + + + +
+
+
+
+
+
+
+ + +
+ +
+
+ + +
+
+
+ Front of men's Basic Tee in black. +
+
+
+

+ + + Basic Tee + +

+

Black

+
+

$35

+
+
+
+
+
+
+<% +=end +%> + + +
diff --git a/app/views/purchases/index.html.erb b/app/views/purchases/index.html.erb index 0f4e7fc..0cd62b4 100644 --- a/app/views/purchases/index.html.erb +++ b/app/views/purchases/index.html.erb @@ -8,6 +8,7 @@
    +
  • <%= link_to tickets_purchases_path do %>
    @@ -26,6 +27,7 @@
    <% end %>
  • +
  • <%= link_to music_purchases_path do %>
    @@ -44,6 +46,26 @@
    <% end %>
  • + +
  • + <%= link_to product_purchases_path do %> +
    +
    +
    +

    Products / Merch

    + Admin +
    +

    + <%= t("purchases.products") %> +

    +
    + + + +
    + <% end %> +
  • +
\ No newline at end of file diff --git a/app/views/sales/_products.erb b/app/views/sales/_products.erb new file mode 100644 index 0000000..86e7492 --- /dev/null +++ b/app/views/sales/_products.erb @@ -0,0 +1,46 @@ +<% item.product_purchase_items.joins(:product).where(products: { user_id: current_user.seller_account_ids }).each do |purchase_item| %> +
  • +
    +
    +
    +
    + <% # image_tag item.product.cover_url(:small), class: "w-20 h-20 object-center object-cover group-hover:opacity-75" %> + +
    + <%= link_to product_show_sale_path(item), class: "text-link" do %> +

    <%= purchase_item.product.title %>

    + <% end %> + + Created: <%= l(item.created_at, format: :short) %> +
    +
    + +
    +
    +

    + Purchased on: <%= l(item.created_at, format: :short) %> +
    + Purchased by: <%= item.user.email %> +

    + +

    + + <%= item.status %> +

    + +
    +
    +
    +
    +
    + <%= link_to sale_path(item) do %> + + <% end %> +
    +
    +
  • +<% end %> diff --git a/app/views/sales/index.html.erb b/app/views/sales/index.html.erb index 686cb78..df87447 100644 --- a/app/views/sales/index.html.erb +++ b/app/views/sales/index.html.erb @@ -36,6 +36,12 @@ <%= t("sales.tracks") %> <% end %> + + <%= link_to sales_path(tab: "products"), phx_click: "section-change", phx_value_section: "all_tracks", + class: "border-transparent text-gray-500 dark:text-gray-200 hover:text-gray-700 hover:text-gray-300 hover:border-gray-200 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm" do %> + <%= t("sales.products_merch") %> + + <% end %>
    @@ -43,11 +49,9 @@
      <% @collection.each do |item| %> - <%= render "tracks", item: item if @tab == "Track" %> <%= render "albums", item: item if @tab == "Album" %> - - + <%= render "products", item: item if @tab == "Product" %> <% end %>
    diff --git a/app/views/sales/product_show.erb b/app/views/sales/product_show.erb new file mode 100644 index 0000000..f6f4ba7 --- /dev/null +++ b/app/views/sales/product_show.erb @@ -0,0 +1,69 @@ + +
    +

    Purchase Details

    + +
    +
    +

    + <%= @product.title %> +

    +

    + Purchased by <%= @purchase.user.email %> on <%= l(@purchase.created_at, format: :long) %> +

    +
    +
    +
    +
    +
    Quantity
    +
    <%= @product_item.product_purchase_items.size %>
    +
    +
    +
    Price
    +
    + <%= number_to_currency(@product_item.total_amount) %> +
    +
    + +
    +
    Payment Status
    +
    + <%= @product_item.status %> +
    +
    + +
    +
    +
    + + <% if @purchase.can_refund? && !@product_item.refunded? %> + +
    +

    Update Purchase

    + <%= form_with(model: @purchase, url: sale_path(@purchase), local: true, class: "space-y-4", data: {turbo_frame: "_top"}) do |form| %> +
    + <%= form.select :shipping_status, ProductPurchase.shipping_statuses.keys.map { |s| [s.titleize, s] }, {}, class: "mt-1 block w-full py-2 px-3 border border-gray-300 bg-muted rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" %> +
    + +
    + <%= form.text_field :tracking_code, class: "mt-1 block w-full py-2 px-3 border border-gray-300 bg-muted rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" %> +
    + + <%= form.submit "Update Purchase", class: "inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-muted bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> + <% end %> +
    + +
    +

    Refund

    + <%= button_to "Refund Product", refund_sale_path(@purchase, item_id: @product_item.id), + method: :post, + data: { turbo_method: :post, turbo_confirm: "Are you sure you want to refund this product?", turbo_frame: "_top"}, + class: "inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500" %> +
    + <% end %> +
    \ No newline at end of file diff --git a/app/views/sales/refund.turbo_stream.erb b/app/views/sales/refund.turbo_stream.erb new file mode 100644 index 0000000..290fe85 --- /dev/null +++ b/app/views/sales/refund.turbo_stream.erb @@ -0,0 +1 @@ +<%= flash_stream %> \ No newline at end of file diff --git a/app/views/shared/_footer.html.erb b/app/views/shared/_footer.html.erb index d6ebe2b..06c5ae1 100644 --- a/app/views/shared/_footer.html.erb +++ b/app/views/shared/_footer.html.erb @@ -1,4 +1,4 @@ -