diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..7cdede6 Binary files /dev/null and b/.DS_Store differ diff --git a/Gemfile.lock b/Gemfile.lock index 5b6acd2..352488f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,10 +7,10 @@ GIT GIT remote: https://github.com/hotwired/turbo-rails.git - revision: e376852bfb273f69f4ebb54cf516b99fcbaa7acb + revision: 9b17a3be3705786d72c3ae77fde5a9b3006555d7 branch: main specs: - turbo-rails (2.0.5) + turbo-rails (2.0.6) actionpack (>= 6.0.0) activejob (>= 6.0.0) railties (>= 6.0.0) @@ -319,7 +319,7 @@ GEM mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) io-console (0.7.2) - irb (1.13.2) + irb (1.14.0) rdoc (>= 4.0.0) reline (>= 0.4.2) jbuilder (2.11.5) @@ -457,7 +457,7 @@ GEM qdrant-ruby (0.9.3) faraday (>= 2.0.1, < 3) racc (1.8.0) - rack (3.1.6) + rack (3.1.7) rack-protection (3.0.6) rack rack-session (2.0.0) diff --git a/app/.DS_Store b/app/.DS_Store new file mode 100644 index 0000000..390d98e Binary files /dev/null and b/app/.DS_Store differ diff --git a/app/controllers/coupons_controller.rb b/app/controllers/coupons_controller.rb new file mode 100644 index 0000000..f941625 --- /dev/null +++ b/app/controllers/coupons_controller.rb @@ -0,0 +1,53 @@ +class CouponsController < ApplicationController + before_action :authenticate_user! + before_action :set_coupon, only: [:show, :edit, :update, :destroy] + + def index + @profile = User.find_by(username: params[:id] || params[:user_id]) + + @coupons = current_user.coupons + end + + def show + end + + def new + @coupon = current_user.coupons.new + end + + def create + @coupon = current_user.coupons.new(coupon_params) + + if @coupon.save + redirect_to user_coupon_path(current_user.username, @coupon), notice: 'Coupon was successfully created.' + else + render :new, status: 422 + end + end + + def edit + end + + def update + if @coupon.update(coupon_params) + redirect_to user_coupon_path(current_user.username, @coupon), notice: 'Coupon was successfully updated.' + else + render :edit + end + end + + def destroy + @coupon.destroy + redirect_to user_coupons_path(current_user.username), notice: 'Coupon was successfully destroyed.' + end + + private + + def set_coupon + @coupon = current_user.coupons.find(params[:id]) + end + + def coupon_params + params.require(:coupon).permit(:code, :discount_type, :discount_amount, :expires_at) + end +end diff --git a/app/controllers/product_checkout_controller.rb b/app/controllers/product_checkout_controller.rb index 0308a69..2356550 100644 --- a/app/controllers/product_checkout_controller.rb +++ b/app/controllers/product_checkout_controller.rb @@ -25,7 +25,7 @@ def create product.product_shippings.pluck(:country) end.uniq - session = Stripe::Checkout::Session.create({ + checkout_params = { payment_method_types: ['card'], line_items: cart_items, mode: 'payment', @@ -45,7 +45,24 @@ def create enabled: true }, shipping_options: generate_shipping_options, - }) + } + + if params[:promo_code].present? + checkout_params.merge!({discounts: [{ coupon: params[:promo_code] }]}) + + @cart.product_cart_items.map(&:product).each do |product| + redirect_to( "/product_cart", notice: "Invalid promo code") and return if product.coupon&.code != params[:promo_code] + end + end + + # allow_promotion_codes: @product&.coupon.exists?, + + begin + session = Stripe::Checkout::Session.create(checkout_params) + rescue Stripe::InvalidRequestError => e + redirect_to "/product_cart", notice: e + return + end @purchase.update(stripe_session_id: session.id) redirect_to session.url, allow_other_host: true diff --git a/app/controllers/products_controller.rb b/app/controllers/products_controller.rb index 507a19a..a5034ae 100644 --- a/app/controllers/products_controller.rb +++ b/app/controllers/products_controller.rb @@ -79,7 +79,7 @@ def destroy private def set_product - @product = Product.friendly.find(params[:id]) + @product = current_user.products.friendly.find(params[:id]) end def authorize_user @@ -90,19 +90,19 @@ def authorize_user 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] + :title, :coupon_id, + :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/helpers/coupons_helper.rb b/app/helpers/coupons_helper.rb new file mode 100644 index 0000000..c60bc4f --- /dev/null +++ b/app/helpers/coupons_helper.rb @@ -0,0 +1,2 @@ +module CouponsHelper +end diff --git a/app/models/coupon.rb b/app/models/coupon.rb new file mode 100644 index 0000000..0efeb35 --- /dev/null +++ b/app/models/coupon.rb @@ -0,0 +1,51 @@ +# app/models/coupon.rb +class Coupon < ApplicationRecord + belongs_to :user + has_many :products + + validates :code, presence: true, uniqueness: true + validates :discount_type, presence: true + validates :discount_amount, presence: true, numericality: { greater_than: 0 } + validates :expires_at, presence: true + + enum discount_type: { percentage: 'percentage', fixed_amount: 'fixed_amount' } + + scope :active, -> { where('expires_at > ?', Time.current) } + + after_create :create_stripe_coupon + before_destroy :delete_stripe_coupon + + def active? + expires_at > Time.current + end + + private + + def create_stripe_coupon + stripe_coupon = if percentage? + Stripe::Coupon.create({ + percent_off: discount_amount, + duration: 'once', + id: code + }) + else + Stripe::Coupon.create({ + amount_off: (discount_amount * 100).to_i, # Convert to cents + currency: 'usd', + duration: 'once', + id: code + }) + end + + update(stripe_id: stripe_coupon.id) + rescue Stripe::StripeError => e + errors.add(:base, "Stripe error: #{e.message}") + throw :abort + end + + def delete_stripe_coupon + Stripe::Coupon.delete(stripe_id) if stripe_id.present? + rescue Stripe::StripeError => e + Rails.logger.error "Failed to delete Stripe coupon: #{e.message}" + end +end \ No newline at end of file diff --git a/app/models/product.rb b/app/models/product.rb index 231abdc..504d29a 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -6,6 +6,10 @@ class Product < ApplicationRecord belongs_to :user belongs_to :album, class_name: 'Playlist', optional: true, foreign_key: :playlist_id + + belongs_to :coupon, optional: true + + has_many :product_variants, dependent: :destroy has_many :product_options, dependent: :destroy has_many :product_images diff --git a/app/models/user.rb b/app/models/user.rb index 7e3c705..7526d03 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -24,6 +24,7 @@ class User < ApplicationRecord has_many :comments has_one :podcaster_info has_many :products + has_many :coupons has_many :product_purchases diff --git a/app/views/coupons/_coupon.erb b/app/views/coupons/_coupon.erb new file mode 100644 index 0000000..bcfcb36 --- /dev/null +++ b/app/views/coupons/_coupon.erb @@ -0,0 +1,20 @@ + + + <%= coupon.code %> + + + <%= coupon.discount_type.titleize %> + + + <%= number_to_currency(coupon.discount_amount) %> + + + <%= coupon.expires_at.strftime("%B %d, %Y at %I:%M %p") %> + + + + <%= link_to 'View', user_coupon_path(current_user.username, coupon), class: 'btn btn-sm btn-info' %> + <%= link_to 'Edit', edit_user_coupon_path(current_user.username, coupon), class: 'btn btn-sm btn-warning' %> + <%= link_to 'Delete', user_coupon_path(current_user.username, coupon), method: :delete, data: { turbo_method: :delete, turbo_confirm: 'Are you sure?' }, class: 'btn btn-sm btn-danger' %> + + diff --git a/app/views/coupons/_form.html.erb b/app/views/coupons/_form.html.erb new file mode 100644 index 0000000..ede011e --- /dev/null +++ b/app/views/coupons/_form.html.erb @@ -0,0 +1,18 @@ + +<%= form_with(model: coupon, url: coupon.new_record? ? user_coupons_path(current_user.username) : user_coupon_path(current_user.username, coupon), local: true, data: {turbo_frame: "_top"}) do |form| %> + +
+ <%= form.text_field :code, class: 'form-control' %> + + <%= form.label :discount_type %> + <%= form.select :discount_type, Coupon.discount_types.keys.map { |type| [type.titleize, type] }, {}, class: 'form-control' %> + + <%= form.number_field :discount_amount, step: 0.01, class: 'form-control' %> + + <%= form.label :expires_at %> + <%= form.datetime_local_field :expires_at, class: 'form-control' %> + + <%= form.submit class: 'btn btn-primary' %> +
+ +<% end %> \ No newline at end of file diff --git a/app/views/coupons/edit.html.erb b/app/views/coupons/edit.html.erb new file mode 100644 index 0000000..1d1c439 --- /dev/null +++ b/app/views/coupons/edit.html.erb @@ -0,0 +1,10 @@ +
+ +

Edit Coupon

+ + <%= render 'form', coupon: @coupon %> + + <%= link_to 'Show', user_coupon_path(current_user.username, @coupon), class: 'btn btn-info' %> | + <%= link_to 'Back to Coupons', user_coupons_path(current_user.username), class: 'btn btn-secondary' %> + +
\ No newline at end of file diff --git a/app/views/coupons/index.html.erb b/app/views/coupons/index.html.erb new file mode 100644 index 0000000..62cbc33 --- /dev/null +++ b/app/views/coupons/index.html.erb @@ -0,0 +1,56 @@ + +
+
+ +
+

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

+ +
+ <%= link_to 'New Coupon', new_user_coupon_path(current_user.username), + class: 'button-sm-outline' %> +
+
+ + + + <% if current_user == @profile %> + + <% end %> + + +
+ + + + + + + + + + + + + + <% @coupons.each do |coupon| %> + <%= render "coupon", coupon: coupon %> + <% end %> + +
CodeDiscount TypeDiscount AmountExpires AtActions
+ +
+ + <% if @coupons.blank? %> +

No coupons found

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

New Coupon

+ + <%= render 'form', coupon: @coupon %> + + <%= link_to 'Back to Coupons', user_coupons_path(current_user.username), class: 'btn btn-secondary' %> + +
\ No newline at end of file diff --git a/app/views/coupons/show.html.erb b/app/views/coupons/show.html.erb new file mode 100644 index 0000000..e7c66b3 --- /dev/null +++ b/app/views/coupons/show.html.erb @@ -0,0 +1,50 @@ + +
+
+

Coupon Information

+

<%= @coupon.code %> details.

+
+
+
+ +
+
Code
+
+ <%= @coupon.code %> +
+
+ +
+
Discount Type
+
+ <%= @coupon.discount_type.titleize %> +
+
+ +
+
Discount Amount
+
+ <%= number_to_currency(@coupon.discount_amount) %> +
+
+ +
+
Expires At
+
+ <%= @coupon.expires_at.strftime("%B %d, %Y at %I:%M %p") %> +
+
+ +
+
Status
+
+ <%= @coupon.active? ? 'Active' : 'Expired' %> +
+
+ + + <%= link_to 'Edit', edit_user_coupon_path(current_user.username, @coupon), class: 'btn btn-warning' %> + <%= link_to 'Back to Coupons', user_coupons_path(current_user.username), class: 'btn btn-secondary' %> +
+
+
diff --git a/app/views/product_cart/_cart.erb b/app/views/product_cart/_cart.erb index 69a081a..dc0e089 100644 --- a/app/views/product_cart/_cart.erb +++ b/app/views/product_cart/_cart.erb @@ -20,6 +20,11 @@

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

<%= form_tag product_checkout_index_path, method: :post, data: {turbo: false } do %> +
+ <%= label_tag :promo_code %> + <%= text_field_tag :promo_code %> + Apply Promo code +
<%= submit_tag "Proceed to Checkout", class: "mt-4 bg-muted text-default px-6 py-3 rounded-lg font-bold cursor-pointer" %> <% end %>
diff --git a/app/views/products/_form.erb b/app/views/products/_form.erb index 8d9e4ea..9299882 100644 --- a/app/views/products/_form.erb +++ b/app/views/products/_form.erb @@ -42,6 +42,8 @@ <%= render "shared/simple_editor", form: form, field: :description %> + +
<%= render "section_header", title: "Photos" %>
@@ -53,6 +55,14 @@ Let fans pay more if they want
+
+ <%= form.label :coupon_id %> + <%= form.collection_select :coupon_id, current_user.coupons.active, :id, :code, { prompt: 'Select a coupon (optional)' }, class: 'form-control' %> +

+ Create coupons <%= link_to "here", user_coupons_path(current_user.username) , class: "text-link" %> +

+
+
<%= form.number_field :stock_quantity %> <%= form.text_field :sku %> diff --git a/app/views/products/index.html.erb b/app/views/products/index.html.erb index d4cae2d..16946e1 100644 --- a/app/views/products/index.html.erb +++ b/app/views/products/index.html.erb @@ -1,14 +1,28 @@ + +
-

- <%= 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 -

+ + +
+

+ <%= 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 +

+
+ + + <% if current_user == @profile %> + + <% end %>
diff --git a/config/routes.rb b/config/routes.rb index 62cd630..0a1b54c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -199,6 +199,7 @@ ] resources :products + resources :coupons resource :podcast, controller: "podcasts" get "followers", to: "user_follows#followers" diff --git a/db/migrate/20240721161504_create_coupons.rb b/db/migrate/20240721161504_create_coupons.rb new file mode 100644 index 0000000..fffdc7b --- /dev/null +++ b/db/migrate/20240721161504_create_coupons.rb @@ -0,0 +1,15 @@ +class CreateCoupons < ActiveRecord::Migration[7.1] + def change + create_table :coupons do |t| + t.references :user, null: false, foreign_key: true + t.string :code, null: false + t.string :discount_type, null: false + t.decimal :discount_amount, null: false, precision: 10, scale: 2 + t.datetime :expires_at, null: false + t.string :stripe_id + + t.timestamps + end + add_index :coupons, :code, unique: true + end +end diff --git a/db/migrate/20240721162149_add_coupon_id_to_product.rb b/db/migrate/20240721162149_add_coupon_id_to_product.rb new file mode 100644 index 0000000..cae170f --- /dev/null +++ b/db/migrate/20240721162149_add_coupon_id_to_product.rb @@ -0,0 +1,5 @@ +class AddCouponIdToProduct < ActiveRecord::Migration[7.1] + def change + add_reference :products, :coupon, null: true, foreign_key: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 7bababf..c86a6c8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_07_13_005658) do +ActiveRecord::Schema[7.1].define(version: 2024_07_21_162149) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -72,6 +72,19 @@ t.index ["user_id"], name: "index_connected_accounts_on_user_id" end + create_table "coupons", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "code", null: false + t.string "discount_type", null: false + t.decimal "discount_amount", precision: 10, scale: 2, null: false + t.datetime "expires_at", null: false + t.string "stripe_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["code"], name: "index_coupons_on_code", unique: true + t.index ["user_id"], name: "index_coupons_on_user_id" + end + create_table "event_hosts", force: :cascade do |t| t.string "name" t.text "description" @@ -447,6 +460,8 @@ t.integer "quantity" t.string "slug" t.datetime "deleted_at" + t.bigint "coupon_id" + t.index ["coupon_id"], name: "index_products_on_coupon_id" t.index ["deleted_at"], name: "index_products_on_deleted_at" t.index ["playlist_id"], name: "index_products_on_playlist_id" t.index ["slug"], name: "index_products_on_slug" @@ -656,6 +671,7 @@ add_foreign_key "comments", "users" add_foreign_key "connected_accounts", "users" add_foreign_key "connected_accounts", "users", column: "parent_id" + add_foreign_key "coupons", "users" add_foreign_key "event_hosts", "events" add_foreign_key "event_hosts", "users" add_foreign_key "event_recordings", "events" @@ -679,6 +695,7 @@ add_foreign_key "product_purchases", "users" add_foreign_key "product_shippings", "products" add_foreign_key "product_variants", "products" + add_foreign_key "products", "coupons" add_foreign_key "products", "playlists" add_foreign_key "products", "users" add_foreign_key "products_images", "products" diff --git a/package.json b/package.json index d254241..56f30b4 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "dependencies": { "@emotion/styled": "^11.11.0", "@hotwired/stimulus": "^3.2.1", - "@hotwired/turbo": "8.0.3", - "@hotwired/turbo-rails": "8.0.3", + "@hotwired/turbo": "8.0.5", + "@hotwired/turbo-rails": "8.0.5", "@josefarias/hotwire_combobox": "^0.2.1", "@rails/activestorage": "^7.0.6", "@rails/request.js": "^0.0.8", diff --git a/spec/factories/coupons.rb b/spec/factories/coupons.rb new file mode 100644 index 0000000..5c6d181 --- /dev/null +++ b/spec/factories/coupons.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :coupon do + user { nil } + code { "MyString" } + discount_type { "9.99" } + expires_at { "2024-07-21 12:15:04" } + stripe_id { "MyString" } + end +end diff --git a/spec/helpers/coupons_helper_spec.rb b/spec/helpers/coupons_helper_spec.rb new file mode 100644 index 0000000..c990366 --- /dev/null +++ b/spec/helpers/coupons_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +# Specs in this file have access to a helper object that includes +# the CouponsHelper. For example: +# +# describe CouponsHelper do +# describe "string concat" do +# it "concats two strings with spaces" do +# expect(helper.concat_strings("this","that")).to eq("this that") +# end +# end +# end +RSpec.describe CouponsHelper, type: :helper do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/models/coupon_spec.rb b/spec/models/coupon_spec.rb new file mode 100644 index 0000000..ab4a444 --- /dev/null +++ b/spec/models/coupon_spec.rb @@ -0,0 +1,5 @@ +require 'rails_helper' + +RSpec.describe Coupon, type: :model do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/requests/coupons_spec.rb b/spec/requests/coupons_spec.rb new file mode 100644 index 0000000..859375f --- /dev/null +++ b/spec/requests/coupons_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +RSpec.describe "Coupons", type: :request do + describe "GET /index" do + pending "add some examples (or delete) #{__FILE__}" + end +end diff --git a/yarn.lock b/yarn.lock index e3de029..3be0f6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -470,18 +470,18 @@ resolved "https://registry.yarnpkg.com/@hotwired/stimulus/-/stimulus-3.2.1.tgz#e3de23623b0c52c247aba4cd5d530d257008676b" integrity sha512-HGlzDcf9vv/EQrMJ5ZG6VWNs8Z/xMN+1o2OhV1gKiSG6CqZt5MCBB1gRg5ILiN3U0jEAxuDTNPRfBcnZBDmupQ== -"@hotwired/turbo-rails@8.0.3": - version "8.0.3" - resolved "https://registry.yarnpkg.com/@hotwired/turbo-rails/-/turbo-rails-8.0.3.tgz#e60375f4eea4b30ec0cd6d7e3fdb3d6349a2b57b" - integrity sha512-n5B9HdFsNiGJfXFAriCArmvFZyznIh/OriB5ZVAWz4Fsi4oLkpgmJNw5pibBAM7NMQQGN6cfKa/nhZT4LWcqbQ== +"@hotwired/turbo-rails@8.0.5": + version "8.0.5" + resolved "https://registry.yarnpkg.com/@hotwired/turbo-rails/-/turbo-rails-8.0.5.tgz#18c2f0e4f7f952307650308590edf5eb9544b0d3" + integrity sha512-1A9G9u28IRAl0C57z8Ka3AhNPyJdwfOrbjr+ABZk2ZEUw2QO7cJ0pgs77asUj2E/tzn1PgrxrSVu24W+1Q5uBA== dependencies: - "@hotwired/turbo" "^8.0.3" + "@hotwired/turbo" "^8.0.5" "@rails/actioncable" "^7.0" -"@hotwired/turbo@8.0.3", "@hotwired/turbo@^8.0.3": - version "8.0.3" - resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-8.0.3.tgz#338e07278f4b3c76921328d3c92dbc4831c209d0" - integrity sha512-qLgp7d6JaegKjMToTJahosrFxV3odfSbiekispQ3soOzE5jnU+iEMWlRvYRe/jvy5Q+JWoywtf9j3RD4ikVjIg== +"@hotwired/turbo@8.0.5", "@hotwired/turbo@^8.0.5": + version "8.0.5" + resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-8.0.5.tgz#abae6dad018a891e4286e87fa0959217e3866d5a" + integrity sha512-TdZDA7fxVQ2ZycygvpnzjGPmFq4sO/E2QVg+2em/sJ3YTSsIWVEis8HmWlumz+c9DjWcUkcCuB+muF08TInpAQ== "@josefarias/hotwire_combobox@^0.2.1": version "0.2.1"