diff --git a/app/controllers/admin/vouchers_controller.rb b/app/controllers/admin/vouchers_controller.rb new file mode 100644 index 0000000000..70bf0c75a1 --- /dev/null +++ b/app/controllers/admin/vouchers_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Admin + class VouchersController < ResourceController + before_action :load_enterprise + + def new + @voucher = Voucher.new + end + + def create + voucher_params = permitted_resource_params.merge(enterprise: @enterprise) + @voucher = Voucher.create(voucher_params) + + if @voucher.save + redirect_to( + "#{edit_admin_enterprise_path(@enterprise)}#vouchers_panel", + flash: { success: flash_message_for(@voucher, :successfully_created) } + ) + else + flash[:error] = @voucher.errors.full_messages.to_sentence + render :new + end + end + + private + + def load_enterprise + @enterprise = Enterprise.find_by permalink: params[:enterprise_id] + end + + def permitted_resource_params + params.require(:voucher).permit(:code) + end + end +end diff --git a/app/helpers/admin/enterprises_helper.rb b/app/helpers/admin/enterprises_helper.rb index e95f4589ed..25dae316f8 100644 --- a/app/helpers/admin/enterprises_helper.rb +++ b/app/helpers/admin/enterprises_helper.rb @@ -14,6 +14,7 @@ module Admin producers.size == 1 ? producers.first.id : nil end + # rubocop:disable Metrics/MethodLength def enterprise_side_menu_items(enterprise) is_shop = enterprise.sells != "none" show_properties = !!enterprise.is_primary_producer @@ -34,6 +35,7 @@ module Admin { name: 'shipping_methods', icon_class: "icon-truck", show: show_shipping_methods }, { name: 'payment_methods', icon_class: "icon-money", show: show_payment_methods }, { name: 'enterprise_fees', icon_class: "icon-tasks", show: show_enterprise_fees }, + { name: 'vouchers', icon_class: "icon-ticket", show: true }, { name: 'enterprise_permissions', icon_class: "icon-plug", show: true, href: admin_enterprise_relationships_path }, { name: 'inventory_settings', icon_class: "icon-list-ol", show: is_shop }, @@ -42,5 +44,6 @@ module Admin { name: 'users', icon_class: "icon-user", show: true } ] end + # rubocop:enable Metrics/MethodLength end end diff --git a/app/models/enterprise.rb b/app/models/enterprise.rb index 3d7d646c6c..a5d4fe6ff6 100644 --- a/app/models/enterprise.rb +++ b/app/models/enterprise.rb @@ -65,6 +65,7 @@ class Enterprise < ApplicationRecord has_many :inventory_items has_many :tag_rules has_one :stripe_account, dependent: :destroy + has_many :vouchers delegate :latitude, :longitude, :city, :state_name, to: :address diff --git a/app/models/spree/ability.rb b/app/models/spree/ability.rb index cfa3fa0f68..dee02528e4 100644 --- a/app/models/spree/ability.rb +++ b/app/models/spree/ability.rb @@ -179,6 +179,8 @@ module Spree can [:admin, :create], :manager_invitation can [:admin, :index], :oidc_setting + + can [:admin, :create], Voucher end def add_product_management_abilities(user) diff --git a/app/models/voucher.rb b/app/models/voucher.rb new file mode 100644 index 0000000000..28c74ca7de --- /dev/null +++ b/app/models/voucher.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: false + +class Voucher < ApplicationRecord + belongs_to :enterprise + + validates :code, presence: true, uniqueness: { scope: :enterprise_id } + + def value + 10 + end + + def display_value + Spree::Money.new(value) + end +end diff --git a/app/views/admin/enterprises/_form.html.haml b/app/views/admin/enterprises/_form.html.haml index dcaae1a149..87aeaa2d29 100644 --- a/app/views/admin/enterprises/_form.html.haml +++ b/app/views/admin/enterprises/_form.html.haml @@ -9,6 +9,12 @@ %fieldset.alpha.no-border-bottom{ id: "#{item[:name]}_panel", data: { "tabs-and-panels-target": "panel" }} %legend= t(".#{ item[:name] }.legend") + - when 'vouchers' + - if feature?(:vouchers, spree_current_user) + %fieldset.alpha.no-border-bottom{ id: "#{item[:name]}_panel", data: { "tabs-and-panels-target": "panel" }} + %legend= t(".#{ item[:form_name] || item[:name] }.legend") + = render "admin/enterprises/form/#{ item[:form_name] || item[:name] }", f: f + - else %fieldset.alpha.no-border-bottom{ id: "#{item[:name]}_panel", data: { "tabs-and-panels-target": "panel" }} %legend= t(".#{ item[:form_name] || item[:name] }.legend") diff --git a/app/views/admin/enterprises/form/_vouchers.html.haml b/app/views/admin/enterprises/form/_vouchers.html.haml new file mode 100644 index 0000000000..8df72f951c --- /dev/null +++ b/app/views/admin/enterprises/form/_vouchers.html.haml @@ -0,0 +1,33 @@ +.text-right + %a.button{ href: "#{new_admin_enterprise_voucher_path(@enterprise)}"} + = t('.add_new') +%br + +- if @enterprise.vouchers.present? + %table + %thead + %tr + %th= t('.voucher_code') + %th= t('.rate') + /%th= t('.label') + /%th= t('.purpose') + /%th= t('.expiry') + /%th= t('.use_limit') + /%th= t('.customers') + /%th= t('.net_value') + %tbody + - @enterprise.vouchers.each do |voucher| + %tr + %td= voucher.code + %td= voucher.display_value + /%td + /%td + /%td + /%td + /%td + /%td + +- else + %p.text-center + = t('.no_voucher_yet') + diff --git a/app/views/admin/shared/_side_menu.html.haml b/app/views/admin/shared/_side_menu.html.haml index 18c9e4a04b..f9f38610bd 100644 --- a/app/views/admin/shared/_side_menu.html.haml +++ b/app/views/admin/shared/_side_menu.html.haml @@ -1,7 +1,7 @@ .side_menu#side_menu - if @enterprise - enterprise_side_menu_items(@enterprise).each do |item| - - next unless item[:show] + - next if !item[:show] || (item[:name] == 'vouchers' && !feature?(:vouchers, spree_current_user)) %a.menu_item{ href: item[:href] || "##{item[:name]}_panel", id: item[:name], data: { action: "tabs-and-panels#changeActivePanel tabs-and-panels#changeActiveTab", "tabs-and-panels-target": "tab" }, class: item[:selected] } %i{ class: item[:icon_class] } %span= t(".enterprise.#{item[:name] }") diff --git a/app/views/admin/vouchers/new.html.haml b/app/views/admin/vouchers/new.html.haml new file mode 100644 index 0000000000..806d49856b --- /dev/null +++ b/app/views/admin/vouchers/new.html.haml @@ -0,0 +1,23 @@ += form_with model: @voucher, url: admin_enterprise_vouchers_path(@enterprise), html: { name: "voucher_form" } do |f| + .row + .sixteen.columns.alpha + .four.columns.alpha.text-right + %a.button{ href: "#{edit_admin_enterprise_path(@enterprise)}#!#vouchers_panel"} + = t('.back') + .twelve.columns.omega + .row + .eight.columns.text-center + %legend= t(".legend") + .four.columns.text-right + = f.submit t('.save'), class: 'red' + .row + .alpha.four.columns + = f.label :code, t('.voucher_code') + .omega.eight.columns + = f.text_area :code, rows: 6, class: 'fullwidth' + .row + .alpha.four.columns + = f.label :amount, t('.voucher_amount') + .omega.eight.columns + = Spree::Money.currency_symbol + = f.text_field :amount, value: @voucher.value, disabled: true diff --git a/app/views/spree/admin/shared/_tabs.html.haml b/app/views/spree/admin/shared/_tabs.html.haml index 6613a24643..91efb77da3 100644 --- a/app/views/spree/admin/shared/_tabs.html.haml +++ b/app/views/spree/admin/shared/_tabs.html.haml @@ -4,7 +4,7 @@ = tab :orders, :subscriptions, :customer_details, :adjustments, :payments, :return_authorizations, url: admin_orders_path('q[s]' => 'completed_at desc'), icon: 'icon-shopping-cart' = tab :reports, url: main_app.admin_reports_path, icon: 'icon-file' = tab :general_settings, :mail_methods, :tax_categories, :tax_rates, :tax_settings, :zones, :countries, :states, :payment_methods, :taxonomies, :shipping_methods, :shipping_categories, :enterprise_fees, :contents, :invoice_settings, :matomo_settings, :stripe_connect_settings, label: 'configuration', icon: 'icon-wrench', url: edit_admin_general_settings_path -= tab :enterprises, :enterprise_relationships, :oidc_settings, url: main_app.admin_enterprises_path += tab :enterprises, :enterprise_relationships, :vouchers, :oidc_settings, url: main_app.admin_enterprises_path = tab :customers, url: main_app.admin_customers_path = tab :enterprise_groups, url: main_app.admin_enterprise_groups_path, label: 'groups' - if can? :admin, Spree::User diff --git a/app/webpacker/controllers/tabs_and_panels_controller.js b/app/webpacker/controllers/tabs_and_panels_controller.js index 0a1762cedb..e9b208fae7 100644 --- a/app/webpacker/controllers/tabs_and_panels_controller.js +++ b/app/webpacker/controllers/tabs_and_panels_controller.js @@ -12,13 +12,40 @@ export default class extends Controller { // only display the default panel this.defaultTarget.style.display = "block"; + + // Display panel specified in url anchor + const anchors = window.location.toString().split("#"); + let anchor = anchors.length > 1 ? anchors.pop() : ""; + + if (anchor != "") { + // Conveniently AngularJs rewrite "example.com#panel" to "example.com#/panel" :( + // strip the starting / if any + if (anchor[0] == "/") { + anchor = anchor.slice(1); + } + + this.updateActivePanel(anchor); + + // tab + const tab_id = anchor.split("_panel").shift(); + this.updateActiveTab(tab_id); + } } changeActivePanel(event) { + this.updateActivePanel(`${event.currentTarget.id}_panel`); + } + + updateActivePanel(panel_id) { const newActivePanel = this.panelTargets.find( - (panel) => panel.id == `${event.currentTarget.id}_panel` + (panel) => panel.id == panel_id ); + if (newActivePanel === undefined) { + // No panel found + return; + } + this.currentActivePanel.style.display = "none"; newActivePanel.style.display = "block"; } @@ -28,6 +55,18 @@ export default class extends Controller { event.currentTarget.classList.add(`${this.classNameValue}`); } + updateActiveTab(tab_id) { + const newActiveTab = this.tabTargets.find((tab) => tab.id == tab_id); + + if (newActiveTab === undefined) { + // No tab found + return; + } + + this.currentActiveTab.classList.remove(`${this.classNameValue}`); + newActiveTab.classList.add(`${this.classNameValue}`); + } + get currentActiveTab() { return this.tabTargets.find((tab) => tab.classList.contains("selected")); } diff --git a/config/locales/en.yml b/config/locales/en.yml index e69f2f8464..50b456fd18 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1143,6 +1143,18 @@ en: add_unregistered_user: "Add an unregistered user" email_confirmed: "Email confirmed" email_not_confirmed: "Email not confirmed" + vouchers: + legend: Vouchers + voucher_code: Voucher Code + rate: Rate + label: Label + purpose: Purpose + expiry: Expiry + use_limit: Use/Limit + customers: Customer + net_value: Net Value + add_new: Add New + no_voucher_yet: No Vouchers yet actions: edit_profile: Settings properties: Properties @@ -1381,6 +1393,7 @@ en: tag_rules: "Tag Rules" shop_preferences: "Shop Preferences" users: "Users" + vouchers: Vouchers enterprise_group: primary_details: "Primary Details" users: "Users" @@ -1589,6 +1602,13 @@ en: schedules: destroy: associated_subscriptions_error: This schedule cannot be deleted because it has associated subscriptions + vouchers: + new: + legend: New Voucher + back: Back + save: Save + voucher_code: Voucher Code + voucher_amount: Amount # Admin controllers controllers: diff --git a/config/routes/admin.rb b/config/routes/admin.rb index b28f1eae57..69e2186cd0 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -40,6 +40,10 @@ Openfoodnetwork::Application.routes.draw do end resources :tag_rules, only: [:destroy] + + constraints FeatureToggleConstraint.new(:vouchers) do + resources :vouchers, only: [:new, :create] + end end resources :enterprise_relationships diff --git a/db/migrate/20230215034821_create_vouchers.rb b/db/migrate/20230215034821_create_vouchers.rb new file mode 100644 index 0000000000..acd2e247a7 --- /dev/null +++ b/db/migrate/20230215034821_create_vouchers.rb @@ -0,0 +1,12 @@ +class CreateVouchers < ActiveRecord::Migration[6.1] + def change + create_table :vouchers do |t| + t.string :code, null: false, limit: 255 + t.datetime :expiry_date + + t.timestamps + end + add_reference :vouchers, :enterprise, foreign_key: true + add_index :vouchers, [:code, :enterprise_id], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 803238dc31..6e7f939670 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1196,6 +1196,16 @@ ActiveRecord::Schema[7.0].define(version: 2023_03_15_031807) do t.index ["user_id"], name: "index_webhook_endpoints_on_user_id" end + create_table "vouchers", force: :cascade do |t| + t.string "code", limit: 255, null: false + t.datetime "expiry_date" + t.datetime "created_at", precision: 6, null: false + t.datetime "updated_at", precision: 6, null: false + t.bigint "enterprise_id" + t.index ["code", "enterprise_id"], name: "index_vouchers_on_code_and_enterprise_id", unique: true + t.index ["enterprise_id"], name: "index_vouchers_on_enterprise_id" + end + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "adjustment_metadata", "enterprises", name: "adjustment_metadata_enterprise_id_fk" @@ -1301,4 +1311,5 @@ ActiveRecord::Schema[7.0].define(version: 2023_03_15_031807) do add_foreign_key "variant_overrides", "enterprises", column: "hub_id", name: "variant_overrides_hub_id_fk" add_foreign_key "variant_overrides", "spree_variants", column: "variant_id", name: "variant_overrides_variant_id_fk" add_foreign_key "webhook_endpoints", "spree_users", column: "user_id" + add_foreign_key "vouchers", "enterprises" end diff --git a/lib/open_food_network/feature_toggle.rb b/lib/open_food_network/feature_toggle.rb index e0b8e49451..ebebffb1de 100644 --- a/lib/open_food_network/feature_toggle.rb +++ b/lib/open_food_network/feature_toggle.rb @@ -37,6 +37,9 @@ module OpenFoodNetwork "split_checkout" => <<~DESC, Replace the one-page checkout with a multi-step checkout. DESC + "vouchers" => <<~DESC, + Add voucher functionality. Voucher can be managed via Enterprise settings. + DESC }.freeze # Move your feature entry from CURRENT_FEATURES to RETIRED_FEATURES when diff --git a/spec/javascripts/stimulus/tabs_and_panels_controller_test.js b/spec/javascripts/stimulus/tabs_and_panels_controller_test.js index 8fdfedd117..523f5eb04f 100644 --- a/spec/javascripts/stimulus/tabs_and_panels_controller_test.js +++ b/spec/javascripts/stimulus/tabs_and_panels_controller_test.js @@ -2,16 +2,26 @@ * @jest-environment jsdom */ -import { Application } from "stimulus"; -import tabs_and_panels_controller from "../../../app/webpacker/controllers/tabs_and_panels_controller"; +import { Application } from 'stimulus'; +import tabs_and_panels_controller from '../../../app/webpacker/controllers/tabs_and_panels_controller'; -describe("EnterprisePanelController", () => { +describe('TabsAndPanelsController', () => { beforeAll(() => { const application = Application.start(); - application.register("tabs-and-panels", tabs_and_panels_controller); + application.register('tabs-and-panels', tabs_and_panels_controller); }); - describe("#tabs-and-panels", () => { + describe('#tabs-and-panels', () => { + const checkDefaultPanel = () => { + const peekPanel = document.getElementById('peek_panel'); + const kaPanel = document.getElementById('ka_panel'); + const booPanel = document.getElementById('boo_panel'); + + expect(peekPanel.style.display).toBe('block'); + expect(kaPanel.style.display).toBe('none'); + expect(booPanel.style.display).toBe('none'); + } + beforeEach(() => { document.body.innerHTML = `