disco_app 0.12.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/Rakefile +37 -0
- data/app/assets/components/disco_app/buttons/model-destroy-button.es6.jsx +31 -0
- data/app/assets/components/disco_app/forms/model-form.es6.jsx +64 -0
- data/app/assets/components/embedded_app/bar.es6.jsx +31 -0
- data/app/assets/components/shopify/buttons/_buttons.scss +547 -0
- data/app/assets/components/shopify/buttons/button.es6.jsx +15 -0
- data/app/assets/components/shopify/card/_card.scss +342 -0
- data/app/assets/components/shopify/card/card-header.es6.jsx +34 -0
- data/app/assets/components/shopify/card/card-section.es6.jsx +26 -0
- data/app/assets/components/shopify/card/card.es6.jsx +16 -0
- data/app/assets/components/shopify/image/_image.scss +82 -0
- data/app/assets/components/shopify/table/_table.scss +18 -0
- data/app/assets/components/shopify/typography/_typography.scss +23 -0
- data/app/assets/components/shopify/typography/ui-heading.es6.jsx +16 -0
- data/app/assets/components/shopify/ui-layout/_ui-layout.scss +157 -0
- data/app/assets/components/shopify/ui-layout/ui-annotated-section.es6.jsx +29 -0
- data/app/assets/components/shopify/ui-layout/ui-empty-state.es6.jsx +35 -0
- data/app/assets/components/shopify/ui-layout/ui-footer-help.es6.jsx +13 -0
- data/app/assets/components/shopify/ui-layout/ui-layout-item.es6.jsx +11 -0
- data/app/assets/components/shopify/ui-layout/ui-layout-section.es6.jsx +19 -0
- data/app/assets/components/shopify/ui-layout/ui-layout-sections.es6.jsx +11 -0
- data/app/assets/components/shopify/ui-layout/ui-layout.es6.jsx +11 -0
- data/app/assets/components/shopify/ui-layout/ui-page-actions.es6.jsx +13 -0
- data/app/assets/components/shopify/ui-layout/ui-page-actions__buttons.es6.jsx +27 -0
- data/app/assets/components/shopify/ui-stack/_ui-stack.scss +39 -0
- data/app/assets/components/shopify/ui-stack/ui-stack-item.es6.jsx +21 -0
- data/app/assets/components/shopify/ui-stack/ui-stack.es6.jsx +24 -0
- data/app/assets/images/disco_app/icon.svg +1 -0
- data/app/assets/images/disco_app/icons.svg +0 -0
- data/app/assets/javascripts/disco_app/components/custom/filterable_shop_list.js.jsx +61 -0
- data/app/assets/javascripts/disco_app/components/custom/inline-radio-options.es6.jsx +59 -0
- data/app/assets/javascripts/disco_app/components/custom/rules-editor.es6.jsx +432 -0
- data/app/assets/javascripts/disco_app/components/custom/shop_filter_query.js.jsx +13 -0
- data/app/assets/javascripts/disco_app/components/custom/shop_filter_tab.js.jsx +34 -0
- data/app/assets/javascripts/disco_app/components/custom/shop_filter_tabs.js.jsx +21 -0
- data/app/assets/javascripts/disco_app/components/custom/shop_list.js.jsx +142 -0
- data/app/assets/javascripts/disco_app/components/custom/shop_row.js.jsx +43 -0
- data/app/assets/javascripts/disco_app/components/custom/shopify_admin_link.js.jsx +29 -0
- data/app/assets/javascripts/disco_app/components/ui-kit/forms/base_form.es6.jsx +72 -0
- data/app/assets/javascripts/disco_app/components/ui-kit/forms/base_input.es6.jsx +20 -0
- data/app/assets/javascripts/disco_app/components/ui-kit/forms/input-checkbox.es6.jsx +30 -0
- data/app/assets/javascripts/disco_app/components/ui-kit/forms/input-radio.es6.jsx +30 -0
- data/app/assets/javascripts/disco_app/components/ui-kit/forms/input-select.es6.jsx +45 -0
- data/app/assets/javascripts/disco_app/components/ui-kit/forms/input-text.es6.jsx +69 -0
- data/app/assets/javascripts/disco_app/components/ui-kit/forms/input-textarea.es6.jsx +48 -0
- data/app/assets/javascripts/disco_app/components/ui-kit/forms/input-time.es6.jsx +7 -0
- data/app/assets/javascripts/disco_app/components/ui-kit/forms/ui-form__element.es6.jsx +17 -0
- data/app/assets/javascripts/disco_app/components/ui-kit/forms/ui-form__group.es6.jsx +11 -0
- data/app/assets/javascripts/disco_app/components/ui-kit/forms/ui-form__section.es6.jsx +11 -0
- data/app/assets/javascripts/disco_app/components/ui-kit/icons/icon-chevron.es6.jsx +33 -0
- data/app/assets/javascripts/disco_app/components/ui-kit/icons/next-icon.es6.jsx +19 -0
- data/app/assets/javascripts/disco_app/components/ui-kit/input_select.es6.jsx +21 -0
- data/app/assets/javascripts/disco_app/components/ui-kit/tables/table.es6.jsx +27 -0
- data/app/assets/javascripts/disco_app/components.js +3 -0
- data/app/assets/javascripts/disco_app/disco_app.js +9 -0
- data/app/assets/javascripts/disco_app/frame.js +152 -0
- data/app/assets/javascripts/disco_app/shopify-turbolinks.js +7 -0
- data/app/assets/javascripts/disco_app/ui-kit.js +1 -0
- data/app/assets/stylesheets/disco_app/admin/_header.scss +75 -0
- data/app/assets/stylesheets/disco_app/admin/_layout.scss +32 -0
- data/app/assets/stylesheets/disco_app/admin/_nav.scss +184 -0
- data/app/assets/stylesheets/disco_app/admin.scss +11 -0
- data/app/assets/stylesheets/disco_app/disco_app.scss +26 -0
- data/app/assets/stylesheets/disco_app/frame/_buttons.scss +54 -0
- data/app/assets/stylesheets/disco_app/frame/_forms.scss +26 -0
- data/app/assets/stylesheets/disco_app/frame/_layout.scss +77 -0
- data/app/assets/stylesheets/disco_app/frame/_type.scss +25 -0
- data/app/assets/stylesheets/disco_app/frame.scss +10 -0
- data/app/assets/stylesheets/disco_app/mixins/_flexbox.scss +400 -0
- data/app/assets/stylesheets/disco_app/ui-kit/_ui-forms.scss +69 -0
- data/app/assets/stylesheets/disco_app/ui-kit/_ui-icons.scss +28 -0
- data/app/assets/stylesheets/disco_app/ui-kit/_ui-kit.scss +5121 -0
- data/app/assets/stylesheets/disco_app/ui-kit/_ui-layout.scss +15 -0
- data/app/assets/stylesheets/disco_app/ui-kit/_ui-tabs.scss +75 -0
- data/app/assets/stylesheets/disco_app/ui-kit/_ui-type.scss +13 -0
- data/app/clients/disco_app/api_client.rb +27 -0
- data/app/clients/disco_app/disco_api_error.rb +2 -0
- data/app/controllers/disco_app/admin/app_settings_controller.rb +3 -0
- data/app/controllers/disco_app/admin/application_controller.rb +10 -0
- data/app/controllers/disco_app/admin/concerns/app_settings_controller.rb +24 -0
- data/app/controllers/disco_app/admin/concerns/authenticated_controller.rb +20 -0
- data/app/controllers/disco_app/admin/concerns/plans_controller.rb +54 -0
- data/app/controllers/disco_app/admin/concerns/shops_controller.rb +7 -0
- data/app/controllers/disco_app/admin/concerns/subscriptions_controller.rb +29 -0
- data/app/controllers/disco_app/admin/plans_controller.rb +3 -0
- data/app/controllers/disco_app/admin/resources/shops_controller.rb +3 -0
- data/app/controllers/disco_app/admin/shops_controller.rb +3 -0
- data/app/controllers/disco_app/admin/subscriptions_controller.rb +3 -0
- data/app/controllers/disco_app/charges_controller.rb +47 -0
- data/app/controllers/disco_app/concerns/app_proxy_controller.rb +40 -0
- data/app/controllers/disco_app/concerns/authenticated_controller.rb +71 -0
- data/app/controllers/disco_app/concerns/carrier_request_controller.rb +35 -0
- data/app/controllers/disco_app/frame_controller.rb +9 -0
- data/app/controllers/disco_app/install_controller.rb +27 -0
- data/app/controllers/disco_app/subscriptions_controller.rb +32 -0
- data/app/controllers/disco_app/webhooks_controller.rb +46 -0
- data/app/controllers/sessions_controller.rb +33 -0
- data/app/helpers/disco_app/application_helper.rb +68 -0
- data/app/jobs/disco_app/app_installed_job.rb +3 -0
- data/app/jobs/disco_app/app_uninstalled_job.rb +3 -0
- data/app/jobs/disco_app/concerns/app_installed_job.rb +39 -0
- data/app/jobs/disco_app/concerns/app_uninstalled_job.rb +21 -0
- data/app/jobs/disco_app/concerns/render_asset_group_job.rb +8 -0
- data/app/jobs/disco_app/concerns/shop_update_job.rb +13 -0
- data/app/jobs/disco_app/concerns/subscription_changed_job.rb +8 -0
- data/app/jobs/disco_app/concerns/synchronise_carrier_service_job.rb +55 -0
- data/app/jobs/disco_app/concerns/synchronise_resources_job.rb +12 -0
- data/app/jobs/disco_app/concerns/synchronise_webhooks_job.rb +52 -0
- data/app/jobs/disco_app/render_asset_group_job.rb +3 -0
- data/app/jobs/disco_app/send_subscription_job.rb +7 -0
- data/app/jobs/disco_app/shop_job.rb +27 -0
- data/app/jobs/disco_app/shop_update_job.rb +3 -0
- data/app/jobs/disco_app/subscription_changed_job.rb +3 -0
- data/app/jobs/disco_app/synchronise_carrier_service_job.rb +3 -0
- data/app/jobs/disco_app/synchronise_resources_job.rb +3 -0
- data/app/jobs/disco_app/synchronise_webhooks_job.rb +3 -0
- data/app/models/disco_app/app_settings.rb +3 -0
- data/app/models/disco_app/application_charge.rb +18 -0
- data/app/models/disco_app/concerns/app_settings.rb +7 -0
- data/app/models/disco_app/concerns/can_be_liquified.rb +45 -0
- data/app/models/disco_app/concerns/has_metafields.rb +48 -0
- data/app/models/disco_app/concerns/plan.rb +26 -0
- data/app/models/disco_app/concerns/plan_code.rb +15 -0
- data/app/models/disco_app/concerns/renders_assets.rb +166 -0
- data/app/models/disco_app/concerns/shop.rb +96 -0
- data/app/models/disco_app/concerns/subscription.rb +66 -0
- data/app/models/disco_app/concerns/synchronises.rb +58 -0
- data/app/models/disco_app/concerns/taggable.rb +16 -0
- data/app/models/disco_app/plan.rb +3 -0
- data/app/models/disco_app/plan_code.rb +3 -0
- data/app/models/disco_app/recurring_application_charge.rb +18 -0
- data/app/models/disco_app/session_storage.rb +18 -0
- data/app/models/disco_app/shop.rb +3 -0
- data/app/models/disco_app/subscription.rb +3 -0
- data/app/resources/disco_app/admin/resources/concerns/shop_resource.rb +100 -0
- data/app/resources/disco_app/admin/resources/shop_resource.rb +4 -0
- data/app/services/disco_app/carrier_request_service.rb +15 -0
- data/app/services/disco_app/charges_service.rb +81 -0
- data/app/services/disco_app/proxy_service.rb +17 -0
- data/app/services/disco_app/request_validation_service.rb +15 -0
- data/app/services/disco_app/subscription_service.rb +46 -0
- data/app/services/disco_app/webhook_service.rb +30 -0
- data/app/views/disco_app/admin/app_settings/edit.html.erb +5 -0
- data/app/views/disco_app/admin/plans/_form.html.erb +72 -0
- data/app/views/disco_app/admin/plans/_plan_code_fields.html.erb +15 -0
- data/app/views/disco_app/admin/plans/edit.html.erb +7 -0
- data/app/views/disco_app/admin/plans/index.html.erb +43 -0
- data/app/views/disco_app/admin/plans/new.html.erb +7 -0
- data/app/views/disco_app/admin/shops/index.html.erb +13 -0
- data/app/views/disco_app/admin/subscriptions/edit.html.erb +33 -0
- data/app/views/disco_app/charges/activate.html.erb +1 -0
- data/app/views/disco_app/charges/create.html.erb +1 -0
- data/app/views/disco_app/charges/new.html.erb +23 -0
- data/app/views/disco_app/frame/frame.html.erb +36 -0
- data/app/views/disco_app/install/installing.html.erb +22 -0
- data/app/views/disco_app/install/uninstalling.html.erb +1 -0
- data/app/views/disco_app/proxy_errors/404.html.erb +1 -0
- data/app/views/disco_app/shared/_card.html.erb +14 -0
- data/app/views/disco_app/shared/_icons.html.erb +1 -0
- data/app/views/disco_app/shared/_section.html.erb +17 -0
- data/app/views/disco_app/subscriptions/new.html.erb +25 -0
- data/app/views/layouts/admin/_nav_items.erb +20 -0
- data/app/views/layouts/admin.html.erb +67 -0
- data/app/views/layouts/application.html.erb +18 -0
- data/app/views/layouts/embedded_app.html.erb +50 -0
- data/app/views/layouts/embedded_app_modal.html.erb +28 -0
- data/app/views/shopify_app/sessions/new.html.erb +42 -0
- data/config/routes.rb +64 -0
- data/db/migrate/20150525000000_create_shops_if_not_existent.rb +110 -0
- data/lib/disco_app/configuration.rb +45 -0
- data/lib/disco_app/constants.rb +4 -0
- data/lib/disco_app/engine.rb +26 -0
- data/lib/disco_app/session.rb +14 -0
- data/lib/disco_app/support/file_fixtures.rb +23 -0
- data/lib/disco_app/test_help.rb +11 -0
- data/lib/disco_app/version.rb +3 -0
- data/lib/disco_app.rb +7 -0
- data/lib/generators/disco_app/USAGE +5 -0
- data/lib/generators/disco_app/disco_app_generator.rb +236 -0
- data/lib/generators/disco_app/templates/assets/javascripts/application.js +17 -0
- data/lib/generators/disco_app/templates/assets/javascripts/components.js +3 -0
- data/lib/generators/disco_app/templates/assets/stylesheets/application.scss +5 -0
- data/lib/generators/disco_app/templates/config/database.yml.tt +20 -0
- data/lib/generators/disco_app/templates/config/newrelic.yml +26 -0
- data/lib/generators/disco_app/templates/config/puma.rb +15 -0
- data/lib/generators/disco_app/templates/controllers/home_controller.rb +7 -0
- data/lib/generators/disco_app/templates/initializers/disco_app.rb +28 -0
- data/lib/generators/disco_app/templates/initializers/rollbar.rb +23 -0
- data/lib/generators/disco_app/templates/initializers/session_store.rb +2 -0
- data/lib/generators/disco_app/templates/initializers/shopify_app.rb +6 -0
- data/lib/generators/disco_app/templates/initializers/shopify_session_repository.rb +7 -0
- data/lib/generators/disco_app/templates/root/CHECKS +4 -0
- data/lib/generators/disco_app/templates/root/Procfile +2 -0
- data/lib/generators/disco_app/templates/views/home/index.html.erb +2 -0
- data/lib/tasks/api.rake +10 -0
- data/lib/tasks/carrier_service.rake +10 -0
- data/lib/tasks/database.rake +8 -0
- data/lib/tasks/sessions.rake +9 -0
- data/lib/tasks/shops.rake +10 -0
- data/lib/tasks/start.rake +3 -0
- data/lib/tasks/webhooks.rake +10 -0
- data/test/clients/disco_app/api_client_test.rb +22 -0
- data/test/controllers/disco_app/admin/shops_controller_test.rb +54 -0
- data/test/controllers/disco_app/charges_controller_test.rb +99 -0
- data/test/controllers/disco_app/install_controller_test.rb +50 -0
- data/test/controllers/disco_app/subscriptions_controller_test.rb +68 -0
- data/test/controllers/disco_app/webhooks_controller_test.rb +58 -0
- data/test/controllers/home_controller_test.rb +101 -0
- data/test/controllers/proxy_controller_test.rb +42 -0
- data/test/disco_app_test.rb +7 -0
- data/test/dummy/Rakefile +6 -0
- data/test/dummy/app/assets/javascripts/application.js +17 -0
- data/test/dummy/app/assets/stylesheets/application.scss +5 -0
- data/test/dummy/app/controllers/application_controller.rb +6 -0
- data/test/dummy/app/controllers/carrier_request_controller.rb +10 -0
- data/test/dummy/app/controllers/disco_app/admin/shops_controller.rb +8 -0
- data/test/dummy/app/controllers/home_controller.rb +7 -0
- data/test/dummy/app/controllers/proxy_controller.rb +8 -0
- data/test/dummy/app/helpers/application_helper.rb +2 -0
- data/test/dummy/app/jobs/carts_update_job.rb +7 -0
- data/test/dummy/app/jobs/disco_app/app_installed_job.rb +16 -0
- data/test/dummy/app/jobs/disco_app/app_uninstalled_job.rb +11 -0
- data/test/dummy/app/jobs/products_create_job.rb +7 -0
- data/test/dummy/app/jobs/products_delete_job.rb +7 -0
- data/test/dummy/app/jobs/products_update_job.rb +7 -0
- data/test/dummy/app/models/cart.rb +24 -0
- data/test/dummy/app/models/disco_app/shop.rb +20 -0
- data/test/dummy/app/models/js_configuration.rb +8 -0
- data/test/dummy/app/models/product.rb +9 -0
- data/test/dummy/app/models/widget_configuration.rb +10 -0
- data/test/dummy/app/views/assets/script_tag.js.erb +1 -0
- data/test/dummy/app/views/assets/test.js.erb +1 -0
- data/test/dummy/app/views/assets/widget.js.erb +2 -0
- data/test/dummy/app/views/assets/widget.scss.erb +3 -0
- data/test/dummy/app/views/home/index.html.erb +2 -0
- data/test/dummy/app/views/snippets/widget.liquid.erb +1 -0
- data/test/dummy/bin/bundle +3 -0
- data/test/dummy/bin/rails +4 -0
- data/test/dummy/bin/rake +4 -0
- data/test/dummy/bin/setup +29 -0
- data/test/dummy/config/application.rb +38 -0
- data/test/dummy/config/boot.rb +5 -0
- data/test/dummy/config/database.codeship.yml +23 -0
- data/test/dummy/config/database.gitlab-ci.yml +24 -0
- data/test/dummy/config/database.yml +20 -0
- data/test/dummy/config/environment.rb +5 -0
- data/test/dummy/config/environments/development.rb +41 -0
- data/test/dummy/config/environments/production.rb +85 -0
- data/test/dummy/config/environments/test.rb +42 -0
- data/test/dummy/config/initializers/assets.rb +11 -0
- data/test/dummy/config/initializers/backtrace_silencers.rb +7 -0
- data/test/dummy/config/initializers/cookies_serializer.rb +3 -0
- data/test/dummy/config/initializers/disco_app.rb +28 -0
- data/test/dummy/config/initializers/filter_parameter_logging.rb +4 -0
- data/test/dummy/config/initializers/inflections.rb +16 -0
- data/test/dummy/config/initializers/mime_types.rb +4 -0
- data/test/dummy/config/initializers/omniauth.rb +7 -0
- data/test/dummy/config/initializers/session_store.rb +2 -0
- data/test/dummy/config/initializers/shopify_app.rb +6 -0
- data/test/dummy/config/initializers/shopify_session_repository.rb +7 -0
- data/test/dummy/config/initializers/wrap_parameters.rb +14 -0
- data/test/dummy/config/locales/en.yml +23 -0
- data/test/dummy/config/routes.rb +11 -0
- data/test/dummy/config/secrets.yml +22 -0
- data/test/dummy/config.ru +4 -0
- data/test/dummy/db/migrate/20160307182229_create_products.rb +11 -0
- data/test/dummy/db/migrate/20160530160739_create_asset_models.rb +19 -0
- data/test/dummy/db/migrate/20161105054746_create_carts.rb +13 -0
- data/test/dummy/db/schema.rb +152 -0
- data/test/dummy/public/404.html +67 -0
- data/test/dummy/public/422.html +67 -0
- data/test/dummy/public/500.html +66 -0
- data/test/dummy/public/favicon.ico +0 -0
- data/test/fixtures/api/subscriptions/valid_request.json +40 -0
- data/test/fixtures/api/widget_store/assets/create_script_tag_js_request.json +6 -0
- data/test/fixtures/api/widget_store/assets/create_script_tag_js_response.json +12 -0
- data/test/fixtures/api/widget_store/assets/create_script_tag_request.json +6 -0
- data/test/fixtures/api/widget_store/assets/create_script_tag_response.json +10 -0
- data/test/fixtures/api/widget_store/assets/create_test_js_request.json +6 -0
- data/test/fixtures/api/widget_store/assets/create_test_js_response.json +12 -0
- data/test/fixtures/api/widget_store/assets/create_widget_js_request.json +6 -0
- data/test/fixtures/api/widget_store/assets/create_widget_js_response.json +12 -0
- data/test/fixtures/api/widget_store/assets/create_widget_liquid_request.json +6 -0
- data/test/fixtures/api/widget_store/assets/create_widget_liquid_response.json +12 -0
- data/test/fixtures/api/widget_store/assets/create_widget_scss_request.json +6 -0
- data/test/fixtures/api/widget_store/assets/create_widget_scss_response.json +12 -0
- data/test/fixtures/api/widget_store/assets/get_script_tags_empty_request.json +1 -0
- data/test/fixtures/api/widget_store/assets/get_script_tags_empty_response.json +1 -0
- data/test/fixtures/api/widget_store/assets/get_script_tags_preexisting_request.json +1 -0
- data/test/fixtures/api/widget_store/assets/get_script_tags_preexisting_response.json +12 -0
- data/test/fixtures/api/widget_store/assets/update_script_tag_request.json +10 -0
- data/test/fixtures/api/widget_store/assets/update_script_tag_response.json +10 -0
- data/test/fixtures/api/widget_store/carrier_services.json +1 -0
- data/test/fixtures/api/widget_store/carrier_services_create.json +8 -0
- data/test/fixtures/api/widget_store/charges/activate_application_charge_request.json +16 -0
- data/test/fixtures/api/widget_store/charges/activate_application_charge_response.json +1 -0
- data/test/fixtures/api/widget_store/charges/activate_recurring_application_charge_request.json +20 -0
- data/test/fixtures/api/widget_store/charges/activate_recurring_application_charge_response.json +1 -0
- data/test/fixtures/api/widget_store/charges/create_application_charge_request.json +9 -0
- data/test/fixtures/api/widget_store/charges/create_application_charge_response.json +16 -0
- data/test/fixtures/api/widget_store/charges/create_recurring_application_charge_request.json +9 -0
- data/test/fixtures/api/widget_store/charges/create_recurring_application_charge_response.json +20 -0
- data/test/fixtures/api/widget_store/charges/create_second_recurring_application_charge_request.json +9 -0
- data/test/fixtures/api/widget_store/charges/create_second_recurring_application_charge_response.json +20 -0
- data/test/fixtures/api/widget_store/charges/get_accepted_application_charge_response.json +16 -0
- data/test/fixtures/api/widget_store/charges/get_accepted_recurring_application_charge_response.json +20 -0
- data/test/fixtures/api/widget_store/charges/get_declined_application_charge_response.json +16 -0
- data/test/fixtures/api/widget_store/charges/get_declined_recurring_application_charge_response.json +20 -0
- data/test/fixtures/api/widget_store/charges/get_pending_application_charge_response.json +16 -0
- data/test/fixtures/api/widget_store/charges/get_pending_recurring_application_charge_response.json +20 -0
- data/test/fixtures/api/widget_store/products/write_metafields_multiple_namespaces_request.json +31 -0
- data/test/fixtures/api/widget_store/products/write_metafields_multiple_namespaces_response.json +1 -0
- data/test/fixtures/api/widget_store/products/write_metafields_single_namespace_request.json +19 -0
- data/test/fixtures/api/widget_store/products/write_metafields_single_namespace_response.json +1 -0
- data/test/fixtures/api/widget_store/shop.json +46 -0
- data/test/fixtures/api/widget_store/webhooks.json +1 -0
- data/test/fixtures/assets/test.js +1 -0
- data/test/fixtures/assets/test.min.js +1 -0
- data/test/fixtures/carts.yml +5 -0
- data/test/fixtures/disco_app/application_charges.yml +11 -0
- data/test/fixtures/disco_app/plan_codes.yml +13 -0
- data/test/fixtures/disco_app/plans.yml +37 -0
- data/test/fixtures/disco_app/recurring_application_charges.yml +13 -0
- data/test/fixtures/disco_app/shops.yml +12 -0
- data/test/fixtures/disco_app/subscriptions.yml +25 -0
- data/test/fixtures/js_configurations.yml +3 -0
- data/test/fixtures/liquid/model.liquid +8 -0
- data/test/fixtures/products.yml +4 -0
- data/test/fixtures/webhooks/app_uninstalled.json +46 -0
- data/test/fixtures/webhooks/cart_updated.json +28 -0
- data/test/fixtures/webhooks/product_created.json +167 -0
- data/test/fixtures/webhooks/product_deleted.json +3 -0
- data/test/fixtures/webhooks/product_updated.json +167 -0
- data/test/fixtures/widget_configurations.yml +4 -0
- data/test/integration/synchronises_test.rb +62 -0
- data/test/jobs/disco_app/app_installed_job_test.rb +57 -0
- data/test/jobs/disco_app/app_uninstalled_job_test.rb +30 -0
- data/test/jobs/disco_app/send_subscription_job_test.rb +24 -0
- data/test/jobs/disco_app/synchronise_carrier_service_job_test.rb +25 -0
- data/test/jobs/disco_app/synchronise_webhooks_job_test.rb +30 -0
- data/test/models/disco_app/can_be_liquified_test.rb +55 -0
- data/test/models/disco_app/has_metafields_test.rb +40 -0
- data/test/models/disco_app/plan_test.rb +5 -0
- data/test/models/disco_app/renders_assets_test.rb +109 -0
- data/test/models/disco_app/session_test.rb +31 -0
- data/test/models/disco_app/shop_test.rb +43 -0
- data/test/models/disco_app/subscription_test.rb +19 -0
- data/test/services/disco_app/charges_service_test.rb +112 -0
- data/test/services/disco_app/subscription_service_test.rb +60 -0
- data/test/support/test_file_fixtures.rb +29 -0
- data/test/support/test_shopify_api.rb +16 -0
- data/test/test_helper.rb +58 -0
- metadata +987 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module DiscoApp::ApplicationHelper
|
|
2
|
+
|
|
3
|
+
# Generates a link pointing to an object (such as an order or customer) inside
|
|
4
|
+
# the given shop's Shopify admin. This helper makes it easy to create links
|
|
5
|
+
# to objects within the admin that support both right-clicking and opening in
|
|
6
|
+
# a new tab as well as capturing a left click and redirecting to the relevant
|
|
7
|
+
# object using `ShopifyApp.redirect()`.
|
|
8
|
+
def link_to_shopify_admin(shop, name, admin_path, options = {})
|
|
9
|
+
options[:onclick] = "ShopifyApp.redirect('#{admin_path}'); return false;"
|
|
10
|
+
options[:'data-no-turbolink'] = true
|
|
11
|
+
link_to(name, "https://#{shop.shopify_domain}/admin/#{admin_path}", options)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Generate a link that will open its href in an embedded Shopify modal.
|
|
15
|
+
def link_to_modal(name, path, options = {})
|
|
16
|
+
modal_options = {
|
|
17
|
+
src: path,
|
|
18
|
+
title: options.delete(:modal_title),
|
|
19
|
+
width: options.delete(:modal_width),
|
|
20
|
+
height: options.delete(:modal_height),
|
|
21
|
+
buttons: options.delete(:modal_buttons),
|
|
22
|
+
}
|
|
23
|
+
options[:onclick] = "ShopifyApp.Modal.open(#{modal_options.to_json}); return false;"
|
|
24
|
+
options[:onclick].gsub!(/"function(.*?)"/, 'function\1')
|
|
25
|
+
link_to(name, path, options)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Render a React component with inner HTML content.
|
|
29
|
+
# Thanks to https://github.com/reactjs/react-rails/pull/166#issuecomment-86178980
|
|
30
|
+
def react_component_with_content(name, args = {}, options = {}, &block)
|
|
31
|
+
args[:__html] = capture(&block) if block.present?
|
|
32
|
+
react_component(name, args, options)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Provide link to dynamically add a new nested fields association
|
|
36
|
+
def link_to_add_fields(name, f, association)
|
|
37
|
+
new_object = f.object.send(association).klass.new
|
|
38
|
+
id = new_object.object_id
|
|
39
|
+
fields = f.fields_for(association, new_object, child_index: id) do |builder|
|
|
40
|
+
render(association.to_s.singularize + "_fields", f: builder)
|
|
41
|
+
end
|
|
42
|
+
link_to(name, '#', class: "add_fields", data: {id: id, fields: fields.gsub("\n", "")})
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Return the props required to instantiate a React ModelForm component for the
|
|
46
|
+
# given model instance.
|
|
47
|
+
def model_form_props(model)
|
|
48
|
+
{
|
|
49
|
+
model: model,
|
|
50
|
+
modelTitle: model.persisted? ? model.to_s : "New #{model.model_name.human.downcase}",
|
|
51
|
+
modelName: model.model_name.singular,
|
|
52
|
+
modelUrl: model.persisted? ? send("#{model.model_name.singular}_path", model) : nil,
|
|
53
|
+
modelsUrl: send("#{model.model_name.plural}_path"),
|
|
54
|
+
authenticityToken: form_authenticity_token,
|
|
55
|
+
errors: errors_to_react(model)
|
|
56
|
+
}.as_json
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Helper method that provides detailed error information from an active record as JSON
|
|
60
|
+
def errors_to_react(model)
|
|
61
|
+
{
|
|
62
|
+
type: model.model_name.human.downcase,
|
|
63
|
+
errors: model.errors.keys,
|
|
64
|
+
messages: model.errors.full_messages
|
|
65
|
+
}.as_json
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module DiscoApp::Concerns::AppInstalledJob
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
included do
|
|
5
|
+
before_enqueue { @shop.awaiting_install! }
|
|
6
|
+
before_perform { @shop.installing! }
|
|
7
|
+
after_perform { @shop.installed! }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Perform application installation.
|
|
11
|
+
#
|
|
12
|
+
# - Synchronise webhooks.
|
|
13
|
+
# - Synchronise carrier service, if required.
|
|
14
|
+
# - Perform initial update of shop information.
|
|
15
|
+
# - Subscribe to default plan, if any exists.
|
|
16
|
+
#
|
|
17
|
+
def perform(shop, plan_code = nil, source = nil)
|
|
18
|
+
DiscoApp::SynchroniseWebhooksJob.perform_now(@shop)
|
|
19
|
+
DiscoApp::SynchroniseCarrierServiceJob.perform_now(@shop)
|
|
20
|
+
DiscoApp::ShopUpdateJob.perform_now(@shop)
|
|
21
|
+
|
|
22
|
+
@shop.reload
|
|
23
|
+
|
|
24
|
+
if default_plan.present?
|
|
25
|
+
DiscoApp::SubscriptionService.subscribe(@shop, default_plan, plan_code, source)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Provide an overridable hook for applications to examine the @shop object
|
|
30
|
+
# and return the default plan, if any, the shop should be subscribed to. If
|
|
31
|
+
# nil is returned, no automatic subscription will take place and the store
|
|
32
|
+
# owner will be forced to choose a plan after installation.
|
|
33
|
+
#
|
|
34
|
+
# If implementing this method, it should be memoized.
|
|
35
|
+
def default_plan
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module DiscoApp::Concerns::AppUninstalledJob
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
included do
|
|
5
|
+
before_enqueue { @shop.awaiting_uninstall! }
|
|
6
|
+
before_perform { @shop.uninstalling! }
|
|
7
|
+
after_perform { @shop.uninstalled! }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Perform application uninstallation.
|
|
11
|
+
#
|
|
12
|
+
# - Mark any recurring application charges as cancelled.
|
|
13
|
+
# - Remove any stored sessions for the shop.
|
|
14
|
+
#
|
|
15
|
+
def perform(shop, shop_data)
|
|
16
|
+
DiscoApp::ChargesService.cancel_recurring_charges(@shop)
|
|
17
|
+
DiscoApp::SendSubscriptionJob.perform_later(@shop)
|
|
18
|
+
@shop.sessions.delete_all
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module DiscoApp::Concerns::ShopUpdateJob
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
# Perform an update of the current shop's information.
|
|
5
|
+
def perform(shop, shop_data = nil)
|
|
6
|
+
# If we weren't provided with shop data (eg from a webhook), fetch it.
|
|
7
|
+
shop_data ||= ActiveSupport::JSON::decode(ShopifyAPI::Shop.current.to_json)
|
|
8
|
+
|
|
9
|
+
# Update attributes stored directly on the Shop model, along with the data hash itself.
|
|
10
|
+
@shop.update(shop_data.with_indifferent_access.slice(*DiscoApp::Shop.column_names).except(:id, :created_at).merge(data: shop_data))
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module DiscoApp::Concerns::SynchroniseCarrierServiceJob
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
# Ensure that any carrier service required by our app is registered.
|
|
5
|
+
def perform(shop)
|
|
6
|
+
# Don't proceed unless we have a name and callback url.
|
|
7
|
+
return unless carrier_service_name and callback_url
|
|
8
|
+
|
|
9
|
+
# Registered the carrier service if it hasn't been registered yet.
|
|
10
|
+
unless current_carrier_service_names.include?(carrier_service_name)
|
|
11
|
+
ShopifyAPI::CarrierService.create(
|
|
12
|
+
name: carrier_service_name,
|
|
13
|
+
callback_url: callback_url,
|
|
14
|
+
service_discovery: true,
|
|
15
|
+
format: :json
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Ensure any existing carrier services (with the correct name) are active
|
|
20
|
+
# and have a current callback URL.
|
|
21
|
+
current_carrier_services.each do |carrier_service|
|
|
22
|
+
if carrier_service.name == carrier_service_name
|
|
23
|
+
carrier_service.callback_url = callback_url
|
|
24
|
+
carrier_service.active = true
|
|
25
|
+
carrier_service.save
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
protected
|
|
31
|
+
|
|
32
|
+
def carrier_service_name
|
|
33
|
+
DiscoApp.configuration.app_name
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def callback_url
|
|
37
|
+
@callback_url ||= begin
|
|
38
|
+
callback_url = DiscoApp.configuration.carrier_service_callback_url
|
|
39
|
+
callback_url.respond_to?('call') ? callback_url.call : callback_url
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Return a list of currently registered carrier service names.
|
|
46
|
+
def current_carrier_service_names
|
|
47
|
+
current_carrier_services.map(&:name)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Return a list of currently registered carrier services.
|
|
51
|
+
def current_carrier_services
|
|
52
|
+
@current_carrier_service ||= ShopifyAPI::CarrierService.find(:all)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
module DiscoApp::Concerns::SynchroniseResourcesJob
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
def perform(shop, class_name, params)
|
|
5
|
+
klass = class_name.constantize
|
|
6
|
+
|
|
7
|
+
klass::SHOPIFY_API_CLASS.find(:all, params: params).map do |shopify_resource|
|
|
8
|
+
klass.synchronise(@shop, shopify_resource.serializable_hash)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
module DiscoApp::Concerns::SynchroniseWebhooksJob
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
# Ensure the webhooks registered with our shop are the same as those listed
|
|
5
|
+
# in our application configuration.
|
|
6
|
+
def perform(shop)
|
|
7
|
+
# Get the full list of expected webhook topics.
|
|
8
|
+
expected_topics = [:'app/uninstalled', :'shop/update'] + (DiscoApp.configuration.webhook_topics || [])
|
|
9
|
+
|
|
10
|
+
# Registered any webhooks that haven't been registered yet.
|
|
11
|
+
(expected_topics - current_topics).each do |topic|
|
|
12
|
+
ShopifyAPI::Webhook.create(
|
|
13
|
+
topic: topic,
|
|
14
|
+
address: webhooks_url,
|
|
15
|
+
format: 'json'
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Remove any extraneous topics.
|
|
20
|
+
current_webhooks.each do |webhook|
|
|
21
|
+
unless expected_topics.include?(webhook.topic.to_sym)
|
|
22
|
+
ShopifyAPI::Webhook.delete(webhook.id)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Ensure webhook addresses are current.
|
|
27
|
+
current_webhooks.each do |webhook|
|
|
28
|
+
unless webhook.address == webhooks_url
|
|
29
|
+
webhook.address = webhooks_url
|
|
30
|
+
webhook.save
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Return a list of currently registered topics.
|
|
38
|
+
def current_topics
|
|
39
|
+
current_webhooks.map(&:topic).map(&:to_sym)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Return a list of current registered webhooks.
|
|
43
|
+
def current_webhooks
|
|
44
|
+
@current_webhooks ||= ShopifyAPI::Webhook.find(:all)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Return the absolute URL to the webhooks endpoint.
|
|
48
|
+
def webhooks_url
|
|
49
|
+
DiscoApp::Engine.routes.url_helpers.webhooks_url
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# The base class for all jobs that should be performed in the context of a
|
|
2
|
+
# particular Shop's API session. The first argument to any job inheriting from
|
|
3
|
+
# this class must be the domain of the relevant store, so that the appropriate
|
|
4
|
+
# Shop model can be fetched and the temporary API session created.
|
|
5
|
+
class DiscoApp::ShopJob < ActiveJob::Base
|
|
6
|
+
|
|
7
|
+
queue_as :default
|
|
8
|
+
|
|
9
|
+
before_perform { |job| find_shop(job) }
|
|
10
|
+
before_enqueue { |job| find_shop(job) }
|
|
11
|
+
|
|
12
|
+
around_enqueue { |job, block| shop_context(job, block) }
|
|
13
|
+
around_perform { |job, block| shop_context(job, block) }
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def find_shop(job)
|
|
18
|
+
@shop ||= job.arguments.first.is_a?(DiscoApp::Shop) ? job.arguments.first : DiscoApp::Shop.find_by!(shopify_domain: job.arguments.first)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def shop_context(job, block)
|
|
22
|
+
@shop.with_api_context {
|
|
23
|
+
block.call(job.arguments)
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class DiscoApp::ApplicationCharge < ActiveRecord::Base
|
|
2
|
+
|
|
3
|
+
belongs_to :shop
|
|
4
|
+
belongs_to :subscription
|
|
5
|
+
|
|
6
|
+
enum status: [:pending, :accepted, :declined, :expired, :active]
|
|
7
|
+
|
|
8
|
+
scope :active, -> { where status: statuses[:active] }
|
|
9
|
+
|
|
10
|
+
def recurring?
|
|
11
|
+
false
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def activate_url
|
|
15
|
+
DiscoApp::Engine.routes.url_helpers.activate_subscription_charge_url(subscription, self)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module DiscoApp::Concerns::CanBeLiquified
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
SPLIT_ARRAY_SEPARATOR = '@!@'
|
|
5
|
+
|
|
6
|
+
included do
|
|
7
|
+
|
|
8
|
+
# Return this model as an array of Liquid {% assign %} statements.
|
|
9
|
+
def as_liquid
|
|
10
|
+
as_json.map { |k, v| "{% assign #{liquid_model_name}_#{k} = #{as_liquid_value(k, v)} %}" }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Render this model as a series of concatenated Liquid {% assign %} statements.
|
|
14
|
+
def to_liquid
|
|
15
|
+
as_liquid.join("\n")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Method to allow override of the model name in Liquid. Useful for models
|
|
19
|
+
# residing in namespaces that would otherwise have very long prefixes.
|
|
20
|
+
def liquid_model_name
|
|
21
|
+
model_name.param_key
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# Return given value as a string expression that will be evaluated in Liquid to result in the correct value type.
|
|
27
|
+
def as_liquid_value(key, value)
|
|
28
|
+
if value.is_a? Numeric or (!!value == value)
|
|
29
|
+
return value.to_s
|
|
30
|
+
end
|
|
31
|
+
if value.nil?
|
|
32
|
+
return 'nil'
|
|
33
|
+
end
|
|
34
|
+
if value.is_a? Array
|
|
35
|
+
return "'#{value.map { |e| (e.is_a? String) ? CGI::escapeHTML(e) : e }.join(SPLIT_ARRAY_SEPARATOR)}' | split: '#{SPLIT_ARRAY_SEPARATOR}'"
|
|
36
|
+
end
|
|
37
|
+
if value.is_a? String and key.end_with? '_html'
|
|
38
|
+
return "'#{value.to_s.gsub("'", "'")}'"
|
|
39
|
+
end
|
|
40
|
+
"'#{CGI::escapeHTML(value.to_s)}'"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module DiscoApp::Concerns::HasMetafields
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
included do
|
|
5
|
+
|
|
6
|
+
# Write multiple metafields for this object in a single call.
|
|
7
|
+
#
|
|
8
|
+
# Expects an argument in a nested hash structure with :namespace => :key =>
|
|
9
|
+
# :value, eg:
|
|
10
|
+
#
|
|
11
|
+
# Product.write_metafields(myapp: {
|
|
12
|
+
# key1: 'value1',
|
|
13
|
+
# key2: 'value2'
|
|
14
|
+
# })
|
|
15
|
+
#
|
|
16
|
+
# This method assumes that it is being called within a valid Shopify API
|
|
17
|
+
# session context, eg @shop.with_api_context { ... }.
|
|
18
|
+
#
|
|
19
|
+
# It also assumes that the including class has defined the appropriate value
|
|
20
|
+
# for SHOPIFY_API_CLASS and that calling the `id` method on the instance
|
|
21
|
+
# will return the relevant object's Shopify ID.
|
|
22
|
+
#
|
|
23
|
+
# Returns true on success, false otherwise.
|
|
24
|
+
def write_metafields(metafields)
|
|
25
|
+
self.class::SHOPIFY_API_CLASS.new(
|
|
26
|
+
id: id,
|
|
27
|
+
metafields: build_metafields(metafields)
|
|
28
|
+
).save
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Give a nested hash of metafields in the format described above, return
|
|
32
|
+
# an array of corresponding ShopifyAPI::Metafield instances.
|
|
33
|
+
def build_metafields(metafields)
|
|
34
|
+
metafields.map do |namespace, keys|
|
|
35
|
+
keys.map do |key, value|
|
|
36
|
+
ShopifyAPI::Metafield.new(
|
|
37
|
+
namespace: namespace,
|
|
38
|
+
key: key,
|
|
39
|
+
value: value,
|
|
40
|
+
value_type: value.is_a?(Integer) ? :integer : :string
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end.flatten
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module DiscoApp::Concerns::Plan
|
|
2
|
+
extend ActiveSupport::Concern
|
|
3
|
+
|
|
4
|
+
included do
|
|
5
|
+
|
|
6
|
+
has_many :subscriptions
|
|
7
|
+
has_many :shops, through: :subscriptions
|
|
8
|
+
has_many :plan_codes, dependent: :destroy
|
|
9
|
+
|
|
10
|
+
accepts_nested_attributes_for :plan_codes, allow_destroy: true
|
|
11
|
+
|
|
12
|
+
enum status: [:available, :unavailable]
|
|
13
|
+
enum plan_type: [:recurring, :one_time]
|
|
14
|
+
enum interval: [:month, :year]
|
|
15
|
+
|
|
16
|
+
scope :available, -> { where status: statuses[:available] }
|
|
17
|
+
|
|
18
|
+
validates_presence_of :name
|
|
19
|
+
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def has_trial?
|
|
23
|
+
trial_period_days.present? and trial_period_days > 0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
end
|