├── .credo.exs ├── .dialyzer_ignore.exs ├── .envrc ├── .envrc.private.example ├── .formatter.exs ├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── push.yml ├── .gitignore ├── .ngrok.yml.example ├── .tool-versions ├── BOILERPLATE_README.md ├── LICENSE ├── README.md ├── assets ├── css │ └── app.css ├── js │ └── app.js ├── tailwind.config.js └── vendor │ └── topbar.js ├── boilerplate-setup.sh ├── config ├── config.exs ├── dev.exs ├── prod.exs ├── runtime.exs └── test.exs ├── docker-compose.yml ├── lib ├── shopify_app.ex ├── shopify_app │ ├── application.ex │ ├── auth_tokens.ex │ ├── config.ex │ ├── graph_ql_loader.ex │ ├── mailer.ex │ ├── plugs │ │ ├── absinthe_admin_wrapper.ex │ │ └── dev_proxy.ex │ ├── repo.ex │ ├── schema.ex │ ├── schema │ │ ├── auth_token.ex │ │ ├── shop.ex │ │ └── user_token.ex │ ├── shopify │ │ ├── create_webhook.ex │ │ └── gql │ │ │ └── webhook_create.graphql │ ├── shopify_api │ │ └── initializer.ex │ ├── shops.ex │ ├── user_tokens.ex │ ├── user_tokens │ │ └── query.ex │ ├── webhook_handler.ex │ ├── workers │ │ └── install │ │ │ └── create_webhook.ex │ └── workflows │ │ ├── app_install.ex │ │ └── app_uninstall.ex ├── shopify_app_graphql │ ├── admin_schema.ex │ ├── resolvers │ │ └── shop.ex │ └── schema │ │ └── shop_types.ex ├── shopify_app_web.ex └── shopify_app_web │ ├── components │ ├── core_components.ex │ ├── layouts.ex │ └── layouts │ │ ├── app.html.heex │ │ └── root.html.heex │ ├── controllers │ ├── error_html.ex │ ├── error_json.ex │ ├── page_controller.ex │ ├── page_html.ex │ ├── page_html │ │ └── home.html.heex │ ├── shop_admin_controller.ex │ ├── shop_admin_html.ex │ └── shop_admin_html │ │ └── index.html.heex │ ├── endpoint.ex │ ├── gettext.ex │ ├── router.ex │ └── telemetry.ex ├── mix.exs ├── mix.lock ├── priv ├── gettext │ ├── en │ │ └── LC_MESSAGES │ │ │ └── errors.po │ └── errors.pot ├── plts │ └── .gitkeep ├── repo │ ├── migrations │ │ ├── .formatter.exs │ │ ├── 20190410195412_create_shops.exs │ │ ├── 20190410195803_create_auth_tokens.exs │ │ ├── 20231005181114_add_user_tokens_table.exs │ │ └── 20231212184732_add_oban_jobs_table.exs │ └── seeds.exs └── static │ ├── favicon.ico │ └── robots.txt ├── shopify.app.boilerplate.toml └── test ├── shopify_app_web └── controllers │ ├── error_html_test.exs │ └── error_json_test.exs ├── support ├── conn_case.ex ├── data_case.ex ├── factory.ex └── shop_test_setup.ex └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [ 35 | ~r"/_build/", 36 | ~r"/deps/", 37 | ~r"/node_modules/", 38 | # Lets not fix the boiler plate phx files 39 | "lib/shopify_app_web/components/core_components.ex", 40 | "test/support/data_case.ex" 41 | ] 42 | }, 43 | # 44 | # Load and configure plugins here: 45 | # 46 | plugins: [], 47 | # 48 | # If you create your own checks, you must specify the source files for 49 | # them here, so they can be loaded by Credo before running the analysis. 50 | # 51 | requires: [], 52 | # 53 | # If you want to enforce a style guide and need a more traditional linting 54 | # experience, you can change `strict` to `true` below: 55 | # 56 | strict: true, 57 | # 58 | # To modify the timeout for parsing files, change this value: 59 | # 60 | parse_timeout: 5000, 61 | # 62 | # If you want to use uncolored output by default, you can change `color` 63 | # to `false` below: 64 | # 65 | color: true, 66 | # 67 | # You can customize the parameters of any check by adding a second element 68 | # to the tuple. 69 | # 70 | # To disable a check put `false` as second element: 71 | # 72 | # {Credo.Check.Design.DuplicatedCode, false} 73 | # 74 | checks: %{ 75 | enabled: [ 76 | # 77 | ## Consistency Checks 78 | # 79 | {Credo.Check.Consistency.ExceptionNames, []}, 80 | {Credo.Check.Consistency.LineEndings, []}, 81 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 82 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 83 | {Credo.Check.Consistency.SpaceInParentheses, []}, 84 | {Credo.Check.Consistency.TabsOrSpaces, []}, 85 | 86 | # 87 | ## Design Checks 88 | # 89 | # You can customize the priority of any check 90 | # Priority values are: `low, normal, high, higher` 91 | # 92 | {Credo.Check.Design.AliasUsage, 93 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 94 | # You can also customize the exit_status of each check. 95 | # If you don't want TODO comments to cause `mix credo` to fail, just 96 | # set this value to 0 (zero). 97 | # 98 | {Credo.Check.Design.TagTODO, [exit_status: 2]}, 99 | {Credo.Check.Design.TagFIXME, []}, 100 | 101 | # 102 | ## Readability Checks 103 | # 104 | {Credo.Check.Readability.AliasOrder, []}, 105 | {Credo.Check.Readability.FunctionNames, []}, 106 | {Credo.Check.Readability.LargeNumbers, []}, 107 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 108 | {Credo.Check.Readability.ModuleAttributeNames, []}, 109 | {Credo.Check.Readability.ModuleDoc, false}, 110 | {Credo.Check.Readability.ModuleNames, []}, 111 | {Credo.Check.Readability.ParenthesesInCondition, []}, 112 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, 113 | {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, 114 | {Credo.Check.Readability.PredicateFunctionNames, []}, 115 | {Credo.Check.Readability.PreferImplicitTry, []}, 116 | {Credo.Check.Readability.RedundantBlankLines, []}, 117 | {Credo.Check.Readability.Semicolons, []}, 118 | {Credo.Check.Readability.SpaceAfterCommas, []}, 119 | {Credo.Check.Readability.StringSigils, []}, 120 | {Credo.Check.Readability.TrailingBlankLine, []}, 121 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 122 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 123 | {Credo.Check.Readability.VariableNames, []}, 124 | {Credo.Check.Readability.WithSingleClause, []}, 125 | 126 | # 127 | ## Refactoring Opportunities 128 | # 129 | {Credo.Check.Refactor.Apply, []}, 130 | {Credo.Check.Refactor.CondStatements, []}, 131 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 132 | {Credo.Check.Refactor.FunctionArity, []}, 133 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 134 | {Credo.Check.Refactor.MatchInCondition, []}, 135 | {Credo.Check.Refactor.MapJoin, []}, 136 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 137 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 138 | {Credo.Check.Refactor.Nesting, []}, 139 | {Credo.Check.Refactor.UnlessWithElse, []}, 140 | {Credo.Check.Refactor.WithClauses, []}, 141 | {Credo.Check.Refactor.FilterFilter, []}, 142 | {Credo.Check.Refactor.RejectReject, []}, 143 | {Credo.Check.Refactor.RedundantWithClauseResult, []}, 144 | 145 | # 146 | ## Warnings 147 | # 148 | {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, 149 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 150 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 151 | {Credo.Check.Warning.IExPry, []}, 152 | {Credo.Check.Warning.IoInspect, []}, 153 | {Credo.Check.Warning.OperationOnSameValues, []}, 154 | {Credo.Check.Warning.OperationWithConstantResult, []}, 155 | {Credo.Check.Warning.RaiseInsideRescue, []}, 156 | {Credo.Check.Warning.SpecWithStruct, false}, 157 | {Credo.Check.Warning.WrongTestFileExtension, []}, 158 | {Credo.Check.Warning.UnusedEnumOperation, []}, 159 | {Credo.Check.Warning.UnusedFileOperation, []}, 160 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 161 | {Credo.Check.Warning.UnusedListOperation, []}, 162 | {Credo.Check.Warning.UnusedPathOperation, []}, 163 | {Credo.Check.Warning.UnusedRegexOperation, []}, 164 | {Credo.Check.Warning.UnusedStringOperation, []}, 165 | {Credo.Check.Warning.UnusedTupleOperation, []}, 166 | {Credo.Check.Warning.UnsafeExec, []} 167 | ], 168 | disabled: [ 169 | # 170 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 171 | 172 | # 173 | # Controversial and experimental checks (opt-in, just move the check to `:enabled` 174 | # and be sure to use `mix credo --strict` to see low priority checks) 175 | # 176 | {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, 177 | {Credo.Check.Consistency.UnusedVariableNames, []}, 178 | {Credo.Check.Design.DuplicatedCode, []}, 179 | {Credo.Check.Design.SkipTestWithoutComment, []}, 180 | {Credo.Check.Readability.AliasAs, []}, 181 | {Credo.Check.Readability.BlockPipe, []}, 182 | {Credo.Check.Readability.ImplTrue, []}, 183 | {Credo.Check.Readability.MultiAlias, []}, 184 | {Credo.Check.Readability.NestedFunctionCalls, []}, 185 | {Credo.Check.Readability.SeparateAliasRequire, []}, 186 | {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, 187 | {Credo.Check.Readability.SinglePipe, []}, 188 | {Credo.Check.Readability.Specs, []}, 189 | {Credo.Check.Readability.StrictModuleLayout, []}, 190 | {Credo.Check.Readability.WithCustomTaggedTuple, []}, 191 | {Credo.Check.Refactor.ABCSize, []}, 192 | {Credo.Check.Refactor.AppendSingleItem, []}, 193 | {Credo.Check.Refactor.DoubleBooleanNegation, []}, 194 | {Credo.Check.Refactor.FilterReject, []}, 195 | {Credo.Check.Refactor.IoPuts, []}, 196 | {Credo.Check.Refactor.MapMap, []}, 197 | {Credo.Check.Refactor.ModuleDependencies, []}, 198 | {Credo.Check.Refactor.NegatedIsNil, []}, 199 | {Credo.Check.Refactor.PipeChainStart, []}, 200 | {Credo.Check.Refactor.RejectFilter, []}, 201 | {Credo.Check.Refactor.VariableRebinding, []}, 202 | {Credo.Check.Warning.LazyLogging, []}, 203 | {Credo.Check.Warning.LeakyEnvironment, []}, 204 | {Credo.Check.Warning.MapGetUnsafePass, []}, 205 | {Credo.Check.Warning.MixEnv, []}, 206 | {Credo.Check.Warning.UnsafeToAtom, []} 207 | 208 | # {Credo.Check.Refactor.MapInto, []}, 209 | 210 | # 211 | # Custom checks can be created using `mix credo.gen.check`. 212 | # 213 | ] 214 | } 215 | } 216 | ] 217 | } 218 | -------------------------------------------------------------------------------- /.dialyzer_ignore.exs: -------------------------------------------------------------------------------- 1 | [ 2 | {"test/support/conn_case.ex:1:unknown_function Function ExUnit.Callbacks.__merge__/3 does not exist."}, 3 | {"test/support/conn_case.ex:20:unknown_function Function ExUnit.CaseTemplate.__proxy__/2 does not exist."}, 4 | {"test/support/data_case.ex:1:unknown_function Function ExUnit.Callbacks.__merge__/3 does not exist."}, 5 | {"test/support/data_case.ex:19:unknown_function Function ExUnit.CaseTemplate.__proxy__/2 does not exist."}, 6 | {"test/support/data_case.ex:40:unknown_function Function ExUnit.Callbacks.on_exit/1 does not exist."} 7 | ] 8 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | export API_KEY="" 2 | export API_SECRET="" 3 | export HOST="" 4 | SHOPIFY_CLI_NO_ANALYTICS=1 5 | 6 | source_env_if_exists .envrc.private 7 | export WEBHOOK_URI=https://$HOST/shopify/webhook/shopify_app 8 | export AUTH_REDIRECT_URI=https://$HOST/shop/authorized/shopify_app 9 | export ADMIN_API_ENDPOINT=https://$HOST/api/admin 10 | export STOREFRONT_API_ENDPOINT=https://$HOST/api/store_front 11 | # vi:syntax=bash 12 | -------------------------------------------------------------------------------- /.envrc.private.example: -------------------------------------------------------------------------------- 1 | export API_KEY="" 2 | export API_SECRET="" 3 | export NGROK_SUBDOMAIN="" 4 | export HOST=$NGROK_SUBDOMAIN.ngrok.io 5 | 6 | # vi:syntax=bash 7 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | import_deps: [:ecto, :ecto_sql, :phoenix], 3 | subdirectories: ["priv/*/migrations"], 4 | plugins: [Phoenix.LiveView.HTMLFormatter], 5 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"] 6 | ] 7 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @pixelunion/apps-developers 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: mix 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: Continuous Integration 3 | jobs: 4 | test: 5 | runs-on: ubuntu-latest 6 | 7 | services: 8 | db: 9 | image: postgres:12 10 | ports: ['5432:5432'] 11 | env: 12 | POSTGRES_USER: postgres 13 | POSTGRES_PASSWORD: postgres 14 | options: >- 15 | --health-cmd pg_isready 16 | --health-interval 10s 17 | --health-timeout 5s 18 | --health-retries 5 19 | 20 | env: 21 | MIX_ENV: test 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Cache dialyzer plts 27 | uses: actions/cache@v4.1.2 28 | with: 29 | path: priv/plts 30 | key: ${{runner.os}}-${{hashFiles('**/.tool-versions')}}-plts 31 | 32 | - name: Setup elixir 33 | uses: erlef/setup-beam@v1 34 | with: 35 | version-file: ".tool-versions" 36 | version-type: "strict" 37 | 38 | - name: Deps get 39 | run: mix deps.get && mix deps.unlock --check-unused 40 | - name: Check Credo 41 | run: mix credo 42 | - name: Check Formatting 43 | run: mix format --check-formatted 44 | - name: Run Tests 45 | run: mix do compile --warnings-as-errors, test 46 | - name: Dialyzer 47 | run: mix dialyzer --halt-exit-status 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | shopify_app-*.tar 24 | 25 | # Ignore assets that are produced by build tools. 26 | /priv/static/assets/ 27 | 28 | # Ignore digested assets cache. 29 | /priv/static/cache_manifest.json 30 | 31 | # dialyzer files 32 | priv/plts/*.plt* 33 | 34 | # Dev tools 35 | .envrc.private 36 | .ngrok.yml 37 | .iex.private.exs 38 | 39 | # In case you use Node.js/npm, you want to ignore these. 40 | npm-debug.log 41 | /assets/node_modules/ 42 | 43 | .envrc.private 44 | -------------------------------------------------------------------------------- /.ngrok.yml.example: -------------------------------------------------------------------------------- 1 | authtoken: 2 | 3 | web_addr: 0.0.0.0:4040 4 | log: stdout 5 | tunnels: 6 | elixir: 7 | addr: host.docker.internal:4000 8 | proto: http 9 | subdomain: 10 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | elixir 1.17.1-otp-27 2 | erlang 27.0 3 | direnv 2.28.0 4 | nodejs 22.4.0 5 | -------------------------------------------------------------------------------- /BOILERPLATE_README.md: -------------------------------------------------------------------------------- 1 | # ShopifyApp 2 | 3 | ## Prerequisite 4 | 5 | - [asdf](https://asdf-vm.com/) 6 | - Docker / Docker Compose 7 | - Access to a Shopify Partners Dashboard 8 | - An ngrok account 9 | 10 | ## Development 11 | 12 | - install with asdf (install the plugins first if you do not have them, e.g. `asdf plugin-add elixir`) 13 | - `asdf install` 14 | - Install Shopify CLI 15 | - `npm install -g @shopify/cli@latest` 16 | - Start database and ngrok 17 | - `docker-compose up` 18 | - Create Shopify App and Shop 19 | - App 20 | - Navigate to apps section on left-hand navigation 21 | - App Setup 22 | - App URL: "https://mysubdomain.ngrok.io/shop_admin" 23 | - Allowed Redirection URL(s): "https://mysubdomain.ngrok.io/shop/authorized/shopify_app" 24 | - GDPR Mandatory Webhooks all fields: "https://mysubdomain.ngrok.io/shopify/webhook/shopify_app" 25 | - Shop 26 | - Navigate to Stores section 27 | - Create a development store 28 | - Navigate to shop dashboard 29 | - Test your App > Select Store > Install 30 | - Setup environment variables 31 | - ensure direnv is working `https://direnv.net/docs/hook.html` 32 | - Create .envrc.private `cp .envrc.private.example .envrc.private` and update values. 33 | - Run `direnv allow` 34 | - Copy .ngrok.yml `cp .ngrok.yml.example .ngrok.yml` and update values 35 | - Fetch Elixir Deps, create database 36 | - `mix deps.get` 37 | - `mix ecto.setup` 38 | 39 | ### Running 40 | 41 | - Run the app locally with `mix phx.server` or `iex -S mix phx.server` for a REPL. 42 | 43 | ### Shop Install 44 | 45 | - Install the the app to a shopify shop with https://mysubdomain.ngrok.io/shop/install?shop=myshop.myshopify.com&app=shopify_app (replacing `mysubdomain` and `app=shopify_app`) 46 | 47 | ### Deploy a specific environment 48 | 49 | ``` 50 | export $(cat .env.dev | xargs) && npx shopify app deploy --config dev 51 | ``` 52 | 53 | This will use the `shopify.app.dev.toml` 54 | 55 | ### Edit App Settings 56 | 57 | Pull down the app settings from the partners dashboard 58 | 59 | ``` 60 | export $(cat .env.dev | xargs) && npx shopify app config link 61 | ``` 62 | 63 | Deploy changes 64 | 65 | ``` 66 | npm run deploy 67 | ``` 68 | 69 | # Organization 70 | 71 | ## Shop Admin 72 | 73 | The Shop Admin is a place inside of Shopify that the merchant can access and configure the app. 74 | 75 | The ShopAdmin is serverd up as a LiveView in an iframe. It uses [AppBridge](https://shopify.dev/docs/api/app-bridge-library/reference) to communicate with the parent. It uses [Polaris](https://polaris.shopify.com) as the design language. 76 | 77 | ### Implementation Notes 78 | 79 | The ShopAdmin lives on the `/shop_admin/:app` route and is part of the `:shop_admin` live_session. Most of the relevant files can be found in `lib/shopify_app_web/live/shop_admin/`. The ShopAdmin has its own root and app layouts, including its own JS which adds a few extra considerations for interacting with AppBridge. 80 | 81 | #### AppBridge Considerations 82 | 83 | - We hook `phx:page-loading-start` events into [AppBridge loading](https://shopify.dev/docs/api/app-bridge-library/reference/loading) 84 | - We can interact with the AppBride [Title Bar with `` componenet](https://shopify.dev/docs/api/app-bridge-library/reference/navigation-menu). We can add action buttons to the title bar. However `` requires that attributes of any of its children do not take the form of `phx-` such as `phx-click`, **instead we must use the `data-phx-` prefix** ( `bindingPrefix: "data-phx-"`). This affects all LiveViews using `use ShopifyAppWeb, :shop_admin_live_view`, which should be all and only ShopAdmin LiveViews. This unfortunately means that we cannot share CoreComponents between ShopAdmin and non ShopAdmin LiveViews. 85 | - We can interact with the AppBridge [Navigation Menu with `ui-nav-menu`](https://shopify.dev/docs/api/app-bridge-library/reference/navigation-menu). This lives in `lib/shopify_app_web/live/shop_admin/layouts/app.html.heex`. AppBridge __should__ detect any navigation within the iframe and reflect that in the Navigation Menu, we re-trigger `history.replaceState` when we navigate between liveviews to give it an extra nudge. In general, AppBridge Navigation Menu updates does not work unless you enter into ShopAdmin from the root path (ie. `/shop_admin/:app` not `/shop_admin/:app/settigns`) 86 | - Instead of traditional `<.flash />` for flash messages, we use the [LiveBridge Toast](https://shopify.dev/docs/api/app-bridge-library/reference/toast) with `<.toast />` (using `ShopifyToastHook`). The toast is still activated with `put_flash/3`. 87 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Elixir Shopify App 2 | 3 | Everything you need to get [Elixir Shopify API](https://github.com/pixelunion/elixir-shopifyapi) up and running. 4 | 5 | ## Usage 6 | 7 | 1. Clone this project `git clone https://github.com/pixelunion/elixir-shopify-app.git` 8 | 1. Rename the directory to a new project name `mv elixir-shopify-app your_project_name` 9 | 1. Delete the internal Git directory `rm -rf .git` 10 | 1. Run the boilerplate setup script `./boilerplate-setup.sh YourProjectName` 11 | 1. Create a new Git repository `git init` 12 | 1. Create the initial Git commit `git add -A` 13 | 1. Create the initial Git commit `git commit -m "Initial commit"` 14 | 1. Setup a Shopify App 15 | - Add the API key and secret to your `.envrc.private` 16 | - Update the allowed URLs to include `https://public-facing-url.ngrok.com/shop/authorized/your_project_name` 17 | 1. Start your database `docker-compose up -d` 18 | 1. Fetch dependencies 19 | - `mix deps.get` 20 | - `mix ecto.setup` 21 | - `cd assets && yarn install && cd ..` 22 | 1. Start your app `mix phx.server` or `iex -S mix phx.server` 23 | 1. Initiate install via `localhost:4000/shop/install?app=new_app_name&shop=mydevshop.myshopify.com` 24 | 25 | ## Credits 26 | 27 | Boilerplate idea and install script borrowed from [Mirego](https://github.com/mirego/elixir-boilerplate) 28 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | /* This file is for your main application CSS */ 6 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel` 2 | // to get started and then uncomment the line below. 3 | // import "./user_socket.js" 4 | 5 | // You can include dependencies in two ways. 6 | // 7 | // The simplest option is to put them in assets/vendor and 8 | // import them using relative paths: 9 | // 10 | // import "../vendor/some-package.js" 11 | // 12 | // Alternatively, you can `npm install some-package --prefix assets` and import 13 | // them using a path starting with the package name: 14 | // 15 | // import "some-package" 16 | // 17 | 18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons. 19 | import "phoenix_html" 20 | // Establish Phoenix Socket and LiveView configuration. 21 | import {Socket} from "phoenix" 22 | import {LiveSocket} from "phoenix_live_view" 23 | import topbar from "../vendor/topbar" 24 | 25 | let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") 26 | let liveSocket = new LiveSocket("/live", Socket, {params: {_csrf_token: csrfToken}}) 27 | 28 | // Show progress bar on live navigation and form submits 29 | topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) 30 | window.addEventListener("phx:page-loading-start", info => topbar.delayedShow(200)) 31 | window.addEventListener("phx:page-loading-stop", info => topbar.hide()) 32 | 33 | // connect if there are any LiveViews on the page 34 | liveSocket.connect() 35 | 36 | // expose liveSocket on window for web console debug logs and latency simulation: 37 | // >> liveSocket.enableDebug() 38 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session 39 | // >> liveSocket.disableLatencySim() 40 | window.liveSocket = liveSocket 41 | 42 | -------------------------------------------------------------------------------- /assets/tailwind.config.js: -------------------------------------------------------------------------------- 1 | // See the Tailwind configuration guide for advanced usage 2 | // https://tailwindcss.com/docs/configuration 3 | 4 | const plugin = require("tailwindcss/plugin") 5 | 6 | module.exports = { 7 | content: [ 8 | "./js/**/*.js", 9 | "../lib/*_web.ex", 10 | "../lib/*_web/**/*.*ex" 11 | ], 12 | theme: { 13 | extend: { 14 | colors: { 15 | brand: "#FD4F00", 16 | } 17 | }, 18 | }, 19 | plugins: [ 20 | require("@tailwindcss/forms"), 21 | plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])), 22 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])), 23 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])), 24 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])) 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /assets/vendor/topbar.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license MIT 3 | * topbar 1.0.0, 2021-01-06 4 | * Modifications: 5 | * - add delayedShow(time) (2022-09-21) 6 | * http://buunguyen.github.io/topbar 7 | * Copyright (c) 2021 Buu Nguyen 8 | */ 9 | (function (window, document) { 10 | "use strict"; 11 | 12 | // https://gist.github.com/paulirish/1579671 13 | (function () { 14 | var lastTime = 0; 15 | var vendors = ["ms", "moz", "webkit", "o"]; 16 | for (var x = 0; x < vendors.length && !window.requestAnimationFrame; ++x) { 17 | window.requestAnimationFrame = 18 | window[vendors[x] + "RequestAnimationFrame"]; 19 | window.cancelAnimationFrame = 20 | window[vendors[x] + "CancelAnimationFrame"] || 21 | window[vendors[x] + "CancelRequestAnimationFrame"]; 22 | } 23 | if (!window.requestAnimationFrame) 24 | window.requestAnimationFrame = function (callback, element) { 25 | var currTime = new Date().getTime(); 26 | var timeToCall = Math.max(0, 16 - (currTime - lastTime)); 27 | var id = window.setTimeout(function () { 28 | callback(currTime + timeToCall); 29 | }, timeToCall); 30 | lastTime = currTime + timeToCall; 31 | return id; 32 | }; 33 | if (!window.cancelAnimationFrame) 34 | window.cancelAnimationFrame = function (id) { 35 | clearTimeout(id); 36 | }; 37 | })(); 38 | 39 | var canvas, 40 | currentProgress, 41 | showing, 42 | progressTimerId = null, 43 | fadeTimerId = null, 44 | delayTimerId = null, 45 | addEvent = function (elem, type, handler) { 46 | if (elem.addEventListener) elem.addEventListener(type, handler, false); 47 | else if (elem.attachEvent) elem.attachEvent("on" + type, handler); 48 | else elem["on" + type] = handler; 49 | }, 50 | options = { 51 | autoRun: true, 52 | barThickness: 3, 53 | barColors: { 54 | 0: "rgba(26, 188, 156, .9)", 55 | ".25": "rgba(52, 152, 219, .9)", 56 | ".50": "rgba(241, 196, 15, .9)", 57 | ".75": "rgba(230, 126, 34, .9)", 58 | "1.0": "rgba(211, 84, 0, .9)", 59 | }, 60 | shadowBlur: 10, 61 | shadowColor: "rgba(0, 0, 0, .6)", 62 | className: null, 63 | }, 64 | repaint = function () { 65 | canvas.width = window.innerWidth; 66 | canvas.height = options.barThickness * 5; // need space for shadow 67 | 68 | var ctx = canvas.getContext("2d"); 69 | ctx.shadowBlur = options.shadowBlur; 70 | ctx.shadowColor = options.shadowColor; 71 | 72 | var lineGradient = ctx.createLinearGradient(0, 0, canvas.width, 0); 73 | for (var stop in options.barColors) 74 | lineGradient.addColorStop(stop, options.barColors[stop]); 75 | ctx.lineWidth = options.barThickness; 76 | ctx.beginPath(); 77 | ctx.moveTo(0, options.barThickness / 2); 78 | ctx.lineTo( 79 | Math.ceil(currentProgress * canvas.width), 80 | options.barThickness / 2 81 | ); 82 | ctx.strokeStyle = lineGradient; 83 | ctx.stroke(); 84 | }, 85 | createCanvas = function () { 86 | canvas = document.createElement("canvas"); 87 | var style = canvas.style; 88 | style.position = "fixed"; 89 | style.top = style.left = style.right = style.margin = style.padding = 0; 90 | style.zIndex = 100001; 91 | style.display = "none"; 92 | if (options.className) canvas.classList.add(options.className); 93 | document.body.appendChild(canvas); 94 | addEvent(window, "resize", repaint); 95 | }, 96 | topbar = { 97 | config: function (opts) { 98 | for (var key in opts) 99 | if (options.hasOwnProperty(key)) options[key] = opts[key]; 100 | }, 101 | delayedShow: function(time) { 102 | if (showing) return; 103 | if (delayTimerId) return; 104 | delayTimerId = setTimeout(() => topbar.show(), time); 105 | }, 106 | show: function () { 107 | if (showing) return; 108 | showing = true; 109 | if (fadeTimerId !== null) window.cancelAnimationFrame(fadeTimerId); 110 | if (!canvas) createCanvas(); 111 | canvas.style.opacity = 1; 112 | canvas.style.display = "block"; 113 | topbar.progress(0); 114 | if (options.autoRun) { 115 | (function loop() { 116 | progressTimerId = window.requestAnimationFrame(loop); 117 | topbar.progress( 118 | "+" + 0.05 * Math.pow(1 - Math.sqrt(currentProgress), 2) 119 | ); 120 | })(); 121 | } 122 | }, 123 | progress: function (to) { 124 | if (typeof to === "undefined") return currentProgress; 125 | if (typeof to === "string") { 126 | to = 127 | (to.indexOf("+") >= 0 || to.indexOf("-") >= 0 128 | ? currentProgress 129 | : 0) + parseFloat(to); 130 | } 131 | currentProgress = to > 1 ? 1 : to; 132 | repaint(); 133 | return currentProgress; 134 | }, 135 | hide: function () { 136 | clearTimeout(delayTimerId); 137 | delayTimerId = null; 138 | if (!showing) return; 139 | showing = false; 140 | if (progressTimerId != null) { 141 | window.cancelAnimationFrame(progressTimerId); 142 | progressTimerId = null; 143 | } 144 | (function loop() { 145 | if (topbar.progress("+.1") >= 1) { 146 | canvas.style.opacity -= 0.05; 147 | if (canvas.style.opacity <= 0.05) { 148 | canvas.style.display = "none"; 149 | fadeTimerId = null; 150 | return; 151 | } 152 | } 153 | fadeTimerId = window.requestAnimationFrame(loop); 154 | })(); 155 | }, 156 | }; 157 | 158 | if (typeof module === "object" && typeof module.exports === "object") { 159 | module.exports = topbar; 160 | } else if (typeof define === "function" && define.amd) { 161 | define(function () { 162 | return topbar; 163 | }); 164 | } else { 165 | this.topbar = topbar; 166 | } 167 | }.call(this, window, document)); 168 | -------------------------------------------------------------------------------- /boilerplate-setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # Borrowed a lot from https://github.com/mirego/elixir-boilerplate 4 | 5 | # ----------------------------------------------------------------------------- 6 | # Configuration 7 | # ----------------------------------------------------------------------------- 8 | 9 | pascalCaseBefore="ShopifyApp" 10 | snakeCaseBefore="shopify_app" 11 | kebabCaseBefore="shopify-app" 12 | 13 | # The identifiers above will be replaced in the content of the files found below 14 | content=$(find . -type f \( \ 15 | -name "*.ex" -or \ 16 | -name "*.exs" -or \ 17 | -name "*.ees" -or \ 18 | -name "*.heex" -or \ 19 | -name "*.sh" -or \ 20 | -name "*.json" -or \ 21 | -name "*.js" -or \ 22 | -name "*.yml" -or \ 23 | -name "*.md" -or \ 24 | -name ".envrc" \ 25 | -name "Dockerfile" \ 26 | \) \ 27 | -and ! -path "./boilerplate-setup.sh" \ 28 | -and ! -path "./assets/node_modules/*" \ 29 | -and ! -path "./_build/*" \ 30 | -and ! -path "./deps/*" \ 31 | ) 32 | 33 | # The identifiers above will be replaced in the path of the files and directories found here 34 | paths=$(find . -depth 2 \( \ 35 | -path "./lib/${snakeCaseBefore}*" -or \ 36 | -path "./lib/${snakeCaseBefore}_*" -or \ 37 | -path "./lib/${snakeCaseBefore}.*" -or \ 38 | -path "./test/${snakeCaseBefore}" -or \ 39 | -path "./test/${snakeCaseBefore}_*" \ 40 | \)) 41 | 42 | # ----------------------------------------------------------------------------- 43 | # Validation 44 | # ----------------------------------------------------------------------------- 45 | 46 | if [[ -z "$1" ]] ; then 47 | echo 'You must specify your project name in PascalCase as first argument.' 48 | exit 0 49 | fi 50 | 51 | pascalCaseAfter=$1 52 | snakeCaseAfter=$(echo $pascalCaseAfter | /usr/bin/sed 's/\(.\)\([A-Z]\)/\1_\2/g' | tr '[:upper:]' '[:lower:]') 53 | kebabCaseAfter=$(echo $snakeCaseAfter | tr '_' '-') 54 | 55 | # ----------------------------------------------------------------------------- 56 | # Helper functions 57 | # ----------------------------------------------------------------------------- 58 | 59 | header() { 60 | echo "\033[0;33m▶ $1\033[0m" 61 | } 62 | 63 | success() { 64 | echo "\033[0;32m▶ $1\033[0m" 65 | } 66 | 67 | run() { 68 | echo ${@} 69 | eval "${@}" 70 | } 71 | 72 | # ----------------------------------------------------------------------------- 73 | # Execution 74 | # ----------------------------------------------------------------------------- 75 | 76 | header "Configuration" 77 | echo "${pascalCaseBefore} → ${pascalCaseAfter}" 78 | echo "${snakeCaseBefore} → ${snakeCaseAfter}" 79 | echo "${kebabCaseBefore} → ${kebabCaseAfter}" 80 | echo "" 81 | 82 | header "Replacing boilerplate identifiers in content" 83 | for file in $content; do 84 | run /usr/bin/sed -i "''" "s/$snakeCaseBefore/$snakeCaseAfter/g" $file 85 | run /usr/bin/sed -i "''" "s/$kebabCaseBefore/$kebabCaseAfter/g" $file 86 | run /usr/bin/sed -i "''" "s/$pascalCaseBefore/$pascalCaseAfter/g" $file 87 | done 88 | success "Done!\n" 89 | 90 | header "Replacing boilerplate identifiers in file and directory paths" 91 | for path in $paths; do 92 | run mv $path $(echo $path | /usr/bin/sed "s/$snakeCaseBefore/$snakeCaseAfter/g" | /usr/bin/sed "s/$kebabCaseBefore/$kebabCaseAfter/g" | /usr/bin/sed "s/$pascalCaseBefore/$pascalCaseAfter/g") 93 | done 94 | success "Done!\n" 95 | 96 | header "Updating Shopify initializers" 97 | run /usr/bin/sed -i "''" "s/$snakeCaseBefore/$snakeCaseAfter/g" lib/$snakeCaseAfter/shopify_api/initializer.ex 98 | success "Done!\n" 99 | 100 | header "Importing project README.md" 101 | run "rm README.md && mv BOILERPLATE_README.md README.md" 102 | success "Done!\n" 103 | 104 | header "Removing boilerplate license → https://choosealicense.com" 105 | run rm LICENSE.md 106 | success "Done!\n" 107 | 108 | header "Removing boilerplate code of conduct and contribution information → https://help.github.com/articles/setting-guidelines-for-repository-contributors/" 109 | run rm CODE_OF_CONDUCT.md CONTRIBUTING.md 110 | success "Done!\n" 111 | 112 | header "Removing boilerplate setup script" 113 | run rm boilerplate-setup.sh 114 | success "Done!\n" 115 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Config module. 3 | # 4 | # This configuration file is loaded before any dependency and 5 | # is restricted to this project. 6 | 7 | # General application configuration 8 | import Config 9 | 10 | config :shopify_app, ShopifyApp.Repo, migration_primary_key: [type: :binary_id] 11 | 12 | config :shopify_app, 13 | ecto_repos: [ShopifyApp.Repo], 14 | generators: [timestamp_type: :utc_datetime, binary_id: true] 15 | 16 | # Configures the endpoint 17 | config :shopify_app, ShopifyAppWeb.Endpoint, 18 | url: [host: "localhost"], 19 | render_errors: [ 20 | formats: [html: ShopifyAppWeb.ErrorHTML, json: ShopifyAppWeb.ErrorJSON], 21 | layout: false 22 | ], 23 | pubsub_server: ShopifyApp.PubSub, 24 | live_view: [signing_salt: "ohBcnlqz"] 25 | 26 | # Configures the mailer 27 | # 28 | # By default it uses the "Local" adapter which stores the emails 29 | # locally. You can see the emails in your browser, at "/dev/mailbox". 30 | # 31 | # For production it's recommended to configure a different adapter 32 | # at the `config/runtime.exs`. 33 | config :shopify_app, ShopifyApp.Mailer, adapter: Swoosh.Adapters.Local 34 | 35 | # Configure esbuild (the version is required) 36 | config :esbuild, 37 | version: "0.14.41", 38 | default: [ 39 | args: 40 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*), 41 | cd: Path.expand("../assets", __DIR__), 42 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} 43 | ] 44 | 45 | # Configure tailwind (the version is required) 46 | config :tailwind, 47 | version: "3.2.4", 48 | default: [ 49 | args: ~w( 50 | --config=tailwind.config.js 51 | --input=css/app.css 52 | --output=../priv/static/assets/app.css 53 | ), 54 | cd: Path.expand("../assets", __DIR__) 55 | ] 56 | 57 | # Configures Elixir's Logger 58 | config :logger, :console, 59 | format: "$time [$level] $message [$metadata]\n", 60 | metadata: [:request_id, :mta, :error, :myshopify_domain, :shopify_object_id] 61 | 62 | # Use Jason for JSON parsing in Phoenix 63 | config :phoenix, :json_library, Jason 64 | 65 | ################### 66 | # ShopifyAPI Config 67 | ################### 68 | config :shopify_api, ShopifyAPI.AuthTokenServer, 69 | initializer: {ShopifyApp.ShopifyAPI.Initializer, :auth_token_init, []}, 70 | persistence: {ShopifyApp.ShopifyAPI.Initializer, :auth_token_persist, []} 71 | 72 | config :shopify_api, ShopifyAPI.AppServer, 73 | initializer: {ShopifyApp.ShopifyAPI.Initializer, :app_init, []}, 74 | persistence: nil 75 | 76 | config :shopify_api, ShopifyAPI.ShopServer, 77 | initializer: {ShopifyApp.ShopifyAPI.Initializer, :shop_init, []}, 78 | persistence: {ShopifyApp.ShopifyAPI.Initializer, :shop_persist, []} 79 | 80 | # Import environment specific config. This must remain at the bottom 81 | # of this file so it overrides the configuration defined above. 82 | import_config "#{config_env()}.exs" 83 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | config :shopify_app, ShopifyApp.Repo, 5 | username: "postgres", 6 | password: "postgres", 7 | hostname: "localhost", 8 | database: "shopify_app_dev", 9 | stacktrace: true, 10 | show_sensitive_data_on_connection_error: true, 11 | pool_size: 10 12 | 13 | # For development, we disable any cache and enable 14 | # debugging and code reloading. 15 | # 16 | # The watchers configuration can be used to run external 17 | # watchers to your application. For example, we use it 18 | # with esbuild to bundle .js and .css sources. 19 | config :shopify_app, ShopifyAppWeb.Endpoint, 20 | # Binding to loopback ipv4 address prevents access from other machines. 21 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines. 22 | http: [ip: {127, 0, 0, 1}, port: 4000], 23 | check_origin: false, 24 | code_reloader: true, 25 | debug_errors: true, 26 | secret_key_base: "AKZhpmq4Y/pC41oalKuYZPceJTzb7uMYv4itlofdbLIiDYPmYSnoUBkSbA4wnpVo", 27 | watchers: [ 28 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]}, 29 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]} 30 | ] 31 | 32 | # ## SSL Support 33 | # 34 | # In order to use HTTPS in development, a self-signed 35 | # certificate can be generated by running the following 36 | # Mix task: 37 | # 38 | # mix phx.gen.cert 39 | # 40 | # Run `mix help phx.gen.cert` for more information. 41 | # 42 | # The `http:` config above can be replaced with: 43 | # 44 | # https: [ 45 | # port: 4001, 46 | # cipher_suite: :strong, 47 | # keyfile: "priv/cert/selfsigned_key.pem", 48 | # certfile: "priv/cert/selfsigned.pem" 49 | # ], 50 | # 51 | # If desired, both `http:` and `https:` keys can be 52 | # configured to run both http and https servers on 53 | # different ports. 54 | 55 | # Watch static and templates for browser reloading. 56 | config :shopify_app, ShopifyAppWeb.Endpoint, 57 | live_reload: [ 58 | patterns: [ 59 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", 60 | ~r"priv/gettext/.*(po)$", 61 | ~r"lib/shopify_app_web/(controllers|live|components)/.*(ex|heex)$" 62 | ] 63 | ] 64 | 65 | # Enable dev routes for dashboard and mailbox 66 | config :shopify_app, dev_routes: true 67 | 68 | # Do not include metadata nor timestamps in development logs 69 | config :logger, :console, format: "[$level] $message\n" 70 | 71 | # Set a higher stacktrace during development. Avoid configuring such 72 | # in production as building large stacktraces may be expensive. 73 | config :phoenix, :stacktrace_depth, 20 74 | 75 | # Initialize plugs at runtime for faster development compilation 76 | config :phoenix, :plug_init_mode, :runtime 77 | 78 | # Disable swoosh api client as it is only required for production adapters. 79 | config :swoosh, :api_client, false 80 | 81 | # Disable query compile time caching for dev 82 | config :shopify_admin_proxy, use_cached_queries: false 83 | 84 | config :reverse_proxy_plug, :http_client, ReverseProxyPlug.HTTPClient.Adapters.HTTPoison 85 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # For production, don't forget to configure the url host 4 | # to something meaningful, Phoenix uses this information 5 | # when generating URLs. 6 | 7 | # Note we also include the path to a cache manifest 8 | # containing the digested version of static files. This 9 | # manifest is generated by the `mix phx.digest` task, 10 | # which you should run after static files are built and 11 | # before starting your production server. 12 | config :shopify_app, ShopifyAppWeb.Endpoint, 13 | cache_static_manifest: "priv/static/cache_manifest.json" 14 | 15 | # Configures Swoosh API Client 16 | config :swoosh, api_client: Swoosh.ApiClient.Finch, finch_name: ShopifyApp.Finch 17 | 18 | # Do not print debug messages in production 19 | config :logger, level: :info 20 | 21 | # Runtime production configuration, including reading 22 | # of environment variables, is done on config/runtime.exs. 23 | -------------------------------------------------------------------------------- /config/runtime.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # config/runtime.exs is executed for all environments, including 4 | # during releases. It is executed after compilation and before the 5 | # system starts, so it is typically used to load production configuration 6 | # and secrets from environment variables or elsewhere. Do not define 7 | # any compile-time configuration in here, as it won't be applied. 8 | # The block below contains prod specific runtime configuration. 9 | 10 | # ## Using releases 11 | # 12 | # If you use `mix release`, you need to explicitly enable the server 13 | # by passing the PHX_SERVER=true when you start it: 14 | # 15 | # PHX_SERVER=true bin/shopify_app start 16 | # 17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server` 18 | # script that automatically sets the env var above. 19 | if System.get_env("PHX_SERVER") do 20 | config :shopify_app, ShopifyAppWeb.Endpoint, server: true 21 | end 22 | 23 | # Add this to your configuration so that ShopifyAPI knows the webhook prefix. 24 | config :shopify_api, ShopifyAPI.Webhook, uri: System.get_env("WEBHOOK_URI") 25 | 26 | unless config_env() == :test do 27 | config :shopify_app, :shopify, 28 | api_key: System.get_env("API_KEY") || raise("environment variable API_KEY is missing"), 29 | api_secret: 30 | System.get_env("API_SECRET") || raise("environment variable API_SECRET is missing"), 31 | auth_redirect_uri: System.get_env("AUTH_REDIRECT_URI"), 32 | admin_api_endpoint: System.get_env("ADMIN_API_ENDPOINT") 33 | end 34 | 35 | if config_env() == :prod do 36 | database_url = 37 | System.get_env("DATABASE_URL") || 38 | raise """ 39 | environment variable DATABASE_URL is missing. 40 | For example: ecto://USER:PASS@HOST/DATABASE 41 | """ 42 | 43 | maybe_ipv6 = if System.get_env("ECTO_IPV6"), do: [:inet6], else: [] 44 | 45 | config :shopify_app, ShopifyApp.Repo, 46 | # ssl: true, 47 | url: database_url, 48 | pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), 49 | socket_options: maybe_ipv6 50 | 51 | # The secret key base is used to sign/encrypt cookies and other secrets. 52 | # A default value is used in config/dev.exs and config/test.exs but you 53 | # want to use a different value for prod and you most likely don't want 54 | # to check this value into version control, so we use an environment 55 | # variable instead. 56 | secret_key_base = 57 | System.get_env("SECRET_KEY_BASE") || 58 | raise """ 59 | environment variable SECRET_KEY_BASE is missing. 60 | You can generate one by calling: mix phx.gen.secret 61 | """ 62 | 63 | host = System.get_env("PHX_HOST") || "example.com" 64 | port = String.to_integer(System.get_env("PORT") || "4000") 65 | 66 | config :shopify_app, ShopifyAppWeb.Endpoint, 67 | url: [host: host, port: 443, scheme: "https"], 68 | http: [ 69 | # Enable IPv6 and bind on all interfaces. 70 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. 71 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html 72 | # for details about using IPv6 vs IPv4 and loopback vs public addresses. 73 | ip: {0, 0, 0, 0, 0, 0, 0, 0}, 74 | port: port 75 | ], 76 | secret_key_base: secret_key_base 77 | 78 | # ## SSL Support 79 | # 80 | # To get SSL working, you will need to add the `https` key 81 | # to your endpoint configuration: 82 | # 83 | # config :shopify_app, ShopifyAppWeb.Endpoint, 84 | # https: [ 85 | # ..., 86 | # port: 443, 87 | # cipher_suite: :strong, 88 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"), 89 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH") 90 | # ] 91 | # 92 | # The `cipher_suite` is set to `:strong` to support only the 93 | # latest and more secure SSL ciphers. This means old browsers 94 | # and clients may not be supported. You can set it to 95 | # `:compatible` for wider support. 96 | # 97 | # `:keyfile` and `:certfile` expect an absolute path to the key 98 | # and cert in disk or a relative path inside priv, for example 99 | # "priv/ssl/server.key". For all supported SSL configuration 100 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1 101 | # 102 | # We also recommend setting `force_ssl` in your endpoint, ensuring 103 | # no data is ever sent via http, always redirecting to https: 104 | # 105 | # config :shopify_app, ShopifyAppWeb.Endpoint, 106 | # force_ssl: [hsts: true] 107 | # 108 | # Check `Plug.SSL` for all available options in `force_ssl`. 109 | 110 | # ## Configuring the mailer 111 | # 112 | # In production you need to configure the mailer to use a different adapter. 113 | # Also, you may need to configure the Swoosh API client of your choice if you 114 | # are not using SMTP. Here is an example of the configuration: 115 | # 116 | # config :shopify_app, ShopifyApp.Mailer, 117 | # adapter: Swoosh.Adapters.Mailgun, 118 | # api_key: System.get_env("MAILGUN_API_KEY"), 119 | # domain: System.get_env("MAILGUN_DOMAIN") 120 | # 121 | # For this example you need include a HTTP client required by Swoosh API client. 122 | # Swoosh supports Hackney and Finch out of the box: 123 | # 124 | # config :swoosh, :api_client, Swoosh.ApiClient.Hackney 125 | # 126 | # See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details. 127 | end 128 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | # Configure your database 4 | # 5 | # The MIX_TEST_PARTITION environment variable can be used 6 | # to provide built-in test partitioning in CI environment. 7 | # Run `mix help test` for more information. 8 | config :shopify_app, ShopifyApp.Repo, 9 | username: "postgres", 10 | password: "postgres", 11 | hostname: "localhost", 12 | database: "shopify_app_test#{System.get_env("MIX_TEST_PARTITION")}", 13 | pool: Ecto.Adapters.SQL.Sandbox, 14 | pool_size: 10 15 | 16 | # We don't run a server during test. If one is required, 17 | # you can enable the server option below. 18 | config :shopify_app, ShopifyAppWeb.Endpoint, 19 | http: [ip: {127, 0, 0, 1}, port: 4002], 20 | secret_key_base: "Y5mnsNSpOIDfYgrd/9yWSXGDurLuICZgS8W+nQ5LAZHGY5GAwO7xhvO1ie2d3g7h", 21 | server: false 22 | 23 | # In test we don't send emails. 24 | config :shopify_app, ShopifyApp.Mailer, adapter: Swoosh.Adapters.Test 25 | 26 | # Disable swoosh api client as it is only required for production adapters. 27 | config :swoosh, :api_client, false 28 | 29 | # Print only warnings and errors during test 30 | config :logger, level: :warning 31 | 32 | # Initialize plugs at runtime for faster test compilation 33 | config :phoenix, :plug_init_mode, :runtime 34 | 35 | config :shopify_app, :shopify, api_key: "test", client_secret: "test" 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.2' 2 | services: 3 | db: 4 | image: "postgres:14" 5 | ports: 6 | - "5432:5432" 7 | environment: 8 | - "POSTGRES_USER=postgres" 9 | - "POSTGRES_PASSWORD=postgres" 10 | ngrok: 11 | image: "ngrok/ngrok:2" 12 | tty: true 13 | ports: 14 | - "4040:4040" 15 | volumes: 16 | - .ngrok.yml:/etc/ngrok.yml 17 | environment: 18 | - NGROK_CONFIG=/etc/ngrok.yml 19 | -------------------------------------------------------------------------------- /lib/shopify_app.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp do 2 | @moduledoc """ 3 | ShopifyApp keeps the contexts that define your domain 4 | and business logic. 5 | 6 | Contexts are also responsible for managing your data, regardless 7 | if it comes from the database, an external API or others. 8 | """ 9 | end 10 | -------------------------------------------------------------------------------- /lib/shopify_app/application.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.Application do 2 | # See https://hexdocs.pm/elixir/Application.html 3 | # for more information on OTP Applications 4 | @moduledoc false 5 | 6 | use Application 7 | 8 | @impl true 9 | def start(_type, _args) do 10 | children = [ 11 | # Start the Telemetry supervisor 12 | ShopifyAppWeb.Telemetry, 13 | # Start the Ecto repository 14 | ShopifyApp.Repo, 15 | # Start the PubSub system 16 | {Phoenix.PubSub, name: ShopifyApp.PubSub}, 17 | # Start Finch 18 | {Finch, name: ShopifyApp.Finch}, 19 | # Start the Endpoint (http/https) 20 | ShopifyAppWeb.Endpoint, 21 | ShopifyAPI.Supervisor 22 | # Start a worker by calling: ShopifyApp.Worker.start_link(arg) 23 | # {ShopifyApp.Worker, arg} 24 | ] 25 | 26 | # See https://hexdocs.pm/elixir/Supervisor.html 27 | # for other strategies and supported options 28 | opts = [strategy: :one_for_one, name: ShopifyApp.Supervisor] 29 | Supervisor.start_link(children, opts) 30 | end 31 | 32 | # Tell Phoenix to update the endpoint configuration 33 | # whenever the application is updated. 34 | @impl true 35 | def config_change(changed, _new, removed) do 36 | ShopifyAppWeb.Endpoint.config_change(changed, removed) 37 | :ok 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/shopify_app/auth_tokens.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.AuthTokens do 2 | @moduledoc false 3 | alias ShopifyApp.Repo 4 | alias ShopifyApp.Schema 5 | 6 | def all, do: Repo.all(Schema.AuthToken) 7 | 8 | def insert(token) do 9 | token 10 | |> find_or_new() 11 | |> Schema.AuthToken.changeset(token) 12 | |> Repo.insert_or_update() 13 | end 14 | 15 | def find_or_new(%{shop_name: shop_name, app_name: app_name}) do 16 | case Repo.get_by(Schema.AuthToken, shop_name: shop_name, app_name: app_name) do 17 | nil -> %Schema.AuthToken{} 18 | auth_token -> auth_token 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/shopify_app/config.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.Config do 2 | @scopes ~W{read_content write_content read_themes write_themes read_products write_products read_product_listings read_customers write_customers read_orders write_orders read_draft_orders write_draft_orders read_inventory write_inventory read_locations read_script_tags write_script_tags read_fulfillments write_fulfillments read_shipping write_shipping read_analytics read_checkouts write_checkouts read_reports write_reports read_price_rules write_price_rules read_marketing_events write_marketing_events read_resource_feedbacks write_resource_feedbacks read_shopify_payments_payouts unauthenticated_read_product_listings unauthenticated_write_checkouts unauthenticated_write_customers unauthenticated_read_content} 3 | @scopes_string Enum.join(@scopes, ",") 4 | 5 | @shop_webhooks ~w/APP_UNINSTALLED SHOP_UPDATE/ 6 | 7 | def app_name, do: "shopify_app" 8 | def app_scopes_string, do: @scopes_string 9 | 10 | def api_key, do: shopify_config(:api_key) 11 | def api_secret, do: shopify_config(:api_secret) 12 | def auth_redirect_uri, do: shopify_config(:auth_redirect_uri) 13 | def admin_api_endpoint, do: shopify_config(:admin_api_endpoint) 14 | 15 | @spec shop_webhooks() :: list(String.t()) 16 | def shop_webhooks, do: @shop_webhooks 17 | 18 | defp shopify_config(key), do: :shopify_app |> Application.get_env(:shopify) |> Keyword.get(key) 19 | end 20 | -------------------------------------------------------------------------------- /lib/shopify_app/graph_ql_loader.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.GraphQLLoader do 2 | @doc """ 3 | GraphQLLoader provides macros for compile time loading of GraphQL queries from files, 4 | and recompile the module using the file if the graphql file changes. The directory 5 | can be passed in on use via the :dir or :directory option. Upon calls to the `load_query_file/1` 6 | macro it will throw a compile time exception if the file is missing. 7 | 8 | example: 9 | 10 | ```elixir 11 | defmodule MyApp.GraphQLQuery do 12 | use ShopifyApp.GraphQLLoader, dir: "graphql_directory" 13 | 14 | @query load_query_file("some_query.graphql") 15 | def do_query do 16 | GraphQLLib.query(@query) 17 | end 18 | end 19 | ``` 20 | 21 | And create the graphql file in `graphql_directory/some_query.graphql`. 22 | """ 23 | 24 | defmacro __using__(opts) do 25 | quote do 26 | graph_ql_dir = Keyword.get(unquote(opts), :dir) || Keyword.get(unquote(opts), :directory) 27 | 28 | unless is_nil(graph_ql_dir) do 29 | Module.put_attribute(__MODULE__, :graph_ql_directory, Path.join(__DIR__, graph_ql_dir)) 30 | end 31 | 32 | import ShopifyApp.GraphQLLoader 33 | end 34 | end 35 | 36 | defmacro graph_ql_directory(dir) do 37 | quote do 38 | Module.put_attribute(__MODULE__, :graph_ql_directory, Path.join(__DIR__, unquote(dir))) 39 | end 40 | end 41 | 42 | defmacro load_query_file(file) do 43 | quote do 44 | graphql_file = 45 | if Module.has_attribute?(__MODULE__, :graph_ql_directory), 46 | do: Path.join(Module.get_attribute(__MODULE__, :graph_ql_directory), unquote(file)), 47 | else: Path.join(__DIR__, unquote(file)) 48 | 49 | Module.put_attribute(__MODULE__, :external_resource, graphql_file) 50 | File.read!(graphql_file) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/shopify_app/mailer.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.Mailer do 2 | use Swoosh.Mailer, otp_app: :shopify_app 3 | end 4 | -------------------------------------------------------------------------------- /lib/shopify_app/plugs/absinthe_admin_wrapper.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.Plug.AdminAbsintheWrapper do 2 | defdelegate init(opts), to: Absinthe.Plug 3 | defdelegate call(conn, opts), to: Absinthe.Plug 4 | end 5 | -------------------------------------------------------------------------------- /lib/shopify_app/plugs/dev_proxy.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.Plug.DevProxy do 2 | if Mix.env() == :dev do 3 | require Logger 4 | import Plug.Conn, only: [halt: 1], warn: false 5 | defdelegate init(opts), to: ReverseProxyPlug 6 | 7 | def call(%{request_path: "/manifest.json"} = conn, opts), 8 | do: conn |> ReverseProxyPlug.call(opts) |> halt() 9 | 10 | def call(%{request_path: "/static/js/bundle.js"} = conn, opts), 11 | do: conn |> ReverseProxyPlug.call(opts) |> halt() 12 | 13 | def call(%{request_path: "/shop_admin" <> _} = conn, opts) do 14 | opts = 15 | Keyword.put(opts, :error_callback, fn error -> 16 | Logger.error("Network error: #{inspect(error)}") 17 | end) 18 | 19 | conn 20 | |> ReverseProxyPlug.call(opts) 21 | |> halt() 22 | end 23 | 24 | def call(conn, _), do: conn 25 | else 26 | def init(_), do: raise("This is for dev only") 27 | def call(_, _), do: raise("This is for dev only") 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/shopify_app/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.Repo do 2 | use Ecto.Repo, 3 | otp_app: :shopify_app, 4 | adapter: Ecto.Adapters.Postgres 5 | 6 | @doc """ 7 | A macro to define a set of standard types for our Schemas. 8 | 9 | Use like so: 10 | ```elixir 11 | defmodule ShopifyApp.SomeModels do 12 | use ShopifyApp.Repo, define_types: ShopifyApp.Schema.SomeModel.t() 13 | end 14 | ``` 15 | """ 16 | def define_types(base_type) do 17 | quote do 18 | @type t() :: unquote(base_type) 19 | @type t_or_nil() :: t() | nil 20 | @type ok_t() :: {:ok, t()} 21 | @type ok_changeset_error() :: ok_t() | {:error, Ecto.Changeset.t(t())} 22 | @type ok_error_not_found() :: ok_t() | {:error, :not_found} 23 | @type ok_errors() :: ok_t() | {:error, Ecto.Changeset.t(t())} | {:error, :not_found} 24 | end 25 | end 26 | 27 | @type record :: record 28 | @spec ok_or_not_found(nil) :: {:error, :not_found} 29 | @spec ok_or_not_found(record) :: {:ok, record} 30 | def ok_or_not_found(nil), do: {:error, :not_found} 31 | def ok_or_not_found(record), do: {:ok, record} 32 | 33 | @doc """ 34 | dispatch to define_types passing through the scheme models type 35 | """ 36 | defmacro __using__([{:define_types, base_type}]), do: define_types(base_type) 37 | end 38 | -------------------------------------------------------------------------------- /lib/shopify_app/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.Schema do 2 | defmacro __using__(args) do 3 | quote do 4 | use Ecto.Schema 5 | 6 | case Keyword.get(unquote(args), :id_type, :binary_id) do 7 | :binary_id -> 8 | @primary_key {:id, :binary_id, autogenerate: true} 9 | @foreign_key_type :binary_id 10 | 11 | :id -> 12 | @primary_key {:id, :id, autogenerate: true} 13 | @foreign_key_type :id 14 | 15 | other -> 16 | raise("unsupported id type, #{inspect(other)}") 17 | end 18 | 19 | @derive {Phoenix.Param, key: :id} 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/shopify_app/schema/auth_token.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.Schema.AuthToken do 2 | @moduledoc false 3 | use ShopifyApp.Schema 4 | import Ecto.Changeset 5 | 6 | schema "auth_tokens" do 7 | field :app_name, :string 8 | field :plus, :boolean, default: false 9 | field :shop_name, :string 10 | field :token, :string 11 | 12 | timestamps() 13 | end 14 | 15 | @doc false 16 | def changeset(auth_token, attrs) do 17 | auth_token 18 | |> cast(attrs, [:app_name, :shop_name, :token, :plus]) 19 | |> validate_required([:app_name, :shop_name, :token, :plus]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/shopify_app/schema/shop.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.Schema.Shop do 2 | @moduledoc false 3 | use ShopifyApp.Schema 4 | import Ecto.Changeset 5 | 6 | @type t :: %__MODULE__{} 7 | 8 | schema "shops" do 9 | field :myshopify_domain, :string 10 | 11 | timestamps() 12 | end 13 | 14 | @doc false 15 | def changeset(shop, attrs) do 16 | shop 17 | |> cast(attrs, [:myshopify_domain]) 18 | |> validate_required([:myshopify_domain]) 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/shopify_app/schema/user_token.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.Schema.UserToken do 2 | @moduledoc false 3 | use ShopifyApp.Schema 4 | import Ecto.Changeset 5 | 6 | alias ShopifyApp.Schema 7 | 8 | @type t :: %__MODULE__{} 9 | 10 | schema "user_tokens" do 11 | field :app_name, :string 12 | field :code, :string 13 | field :token, :string 14 | field :associated_user_id, :integer 15 | field :associated_user_scope, :string 16 | field :timestamp, :integer 17 | field :plus, :boolean, default: false 18 | field :scope, :string 19 | field :expires_in, :integer 20 | field :associated_user, :map 21 | 22 | belongs_to :shop, Schema.Shop, 23 | references: :myshopify_domain, 24 | foreign_key: :shop_myshopify_domain, 25 | type: :string 26 | 27 | timestamps() 28 | end 29 | 30 | @doc false 31 | def changeset(auth_token, attrs) do 32 | auth_token 33 | |> cast(attrs, [ 34 | :app_name, 35 | :shop_myshopify_domain, 36 | :code, 37 | :token, 38 | :associated_user_id, 39 | :associated_user_scope, 40 | :timestamp, 41 | :plus, 42 | :scope, 43 | :expires_in, 44 | :associated_user 45 | ]) 46 | |> validate_required([:app_name, :shop_myshopify_domain, :token, :plus]) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/shopify_app/shopify/create_webhook.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.Shopify.CreateWebhook do 2 | use ShopifyApp.GraphQLLoader, dir: "gql" 3 | require Logger 4 | 5 | @type response() :: {:ok, map() | :already_created} | {:error, :create_webhook} 6 | 7 | @error_addr_already_taken "Address for this topic has already been taken" 8 | @gql load_query_file("webhook_create.graphql") 9 | 10 | @spec create(ShopifyAPI.AuthToken.t(), String.t()) :: response() 11 | def create(token, topic) do 12 | callback_url = ShopifyAppWeb.Router.webhooks_url() 13 | 14 | case ShopifyAPI.GraphQL.query(token, @gql, %{topic: topic, callback_url: callback_url}) do 15 | {:ok, 16 | %{ 17 | response: %{ 18 | "webhookSubscriptionCreate" => %{ 19 | "userErrors" => [%{"message" => @error_addr_already_taken}] 20 | } 21 | } 22 | }} -> 23 | {:ok, :already_created} 24 | 25 | {:ok, %{response: %{"webhookSubscriptionCreate" => %{"webhook" => webhook}}}} -> 26 | {:ok, webhook} 27 | 28 | {:ok, 29 | %{response: %{"webhookSubscriptionCreate" => %{"userErrors" => user_errors = [_ | _]}}}} -> 30 | Logger.error("create webhook received user errors: #{inspect(user_errors)}") 31 | 32 | {:error, :create_webhook} 33 | 34 | {:error, error} -> 35 | Logger.error("create webhook received an unknown error: #{inspect(error)}") 36 | 37 | {:error, :create_webhook} 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/shopify_app/shopify/gql/webhook_create.graphql: -------------------------------------------------------------------------------- 1 | mutation WebHookSubscriptionCreate($topic: WebhookSubscriptionTopic!, $callback_url: URL) { 2 | webhookSubscriptionCreate( 3 | topic: $topic 4 | webhookSubscription: { 5 | callbackUrl: $callback_url 6 | format: JSON 7 | } 8 | ) { 9 | userErrors { 10 | field 11 | message 12 | } 13 | webhookSubscription { 14 | includeFields 15 | topic 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/shopify_app/shopify_api/initializer.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.ShopifyAPI.Initializer do 2 | @moduledoc false 3 | require Logger 4 | 5 | def app_init do 6 | [ 7 | %ShopifyAPI.App{ 8 | name: ShopifyApp.Config.app_name(), 9 | client_id: ShopifyApp.Config.api_key(), 10 | client_secret: ShopifyApp.Config.api_secret(), 11 | auth_redirect_uri: ShopifyApp.Config.auth_redirect_uri(), 12 | nonce: "test", 13 | scope: ShopifyApp.Config.app_scopes_string() 14 | } 15 | ] 16 | end 17 | 18 | def shop_init, do: Enum.map(ShopifyApp.Shops.all(), &to_shopify_shop_struct/1) 19 | 20 | def shop_persist(%ShopifyAPI.AuthToken{shop_name: myshopify_domain}), 21 | do: ShopifyApp.Shops.insert(%{myshopify_domain: myshopify_domain}) 22 | 23 | def shop_persist(_key, %ShopifyAPI.Shop{domain: myshopify_domain}), 24 | do: ShopifyApp.Shops.insert(%{myshopify_domain: myshopify_domain}) 25 | 26 | def auth_token_init, do: Enum.map(ShopifyApp.AuthTokens.all(), &to_shopify_token_struct/1) 27 | 28 | def auth_token_persist(_key, %ShopifyAPI.AuthToken{} = token) do 29 | token 30 | |> Map.from_struct() 31 | |> ShopifyApp.AuthTokens.insert() 32 | end 33 | 34 | defp to_shopify_shop_struct(%{myshopify_domain: domain}), do: %ShopifyAPI.Shop{domain: domain} 35 | 36 | defp to_shopify_token_struct(%{app_name: app_name, shop_name: shop_name, token: token}) do 37 | %ShopifyAPI.AuthToken{ 38 | app_name: app_name, 39 | shop_name: shop_name, 40 | token: token, 41 | timestamp: 0, 42 | plus: false 43 | } 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/shopify_app/shops.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.Shops do 2 | @moduledoc false 3 | use ShopifyApp.Repo, define_types: ShopifyApp.Schema.Shop.t() 4 | alias ShopifyApp.Repo 5 | alias ShopifyApp.Schema 6 | 7 | def all, do: Repo.all(Schema.Shop) 8 | 9 | @spec insert(map()) :: ok_changeset_error() 10 | def insert(shop), 11 | do: shop |> find_or_new() |> Schema.Shop.changeset(shop) |> Repo.insert_or_update() 12 | 13 | @spec find(String.t()) :: t() | nil 14 | def find(myshopify_domain), do: Repo.get_by(Schema.Shop, myshopify_domain: myshopify_domain) 15 | 16 | @spec find_or_new(map()) :: t() 17 | def find_or_new(%{myshopify_domain: myshopify_domain}) do 18 | case find(myshopify_domain) do 19 | nil -> %Schema.Shop{} 20 | shop -> shop 21 | end 22 | end 23 | 24 | @spec delete(t()) :: ok_changeset_error() 25 | def delete(shop) when is_struct(shop, Schema.Shop), do: Repo.delete(shop) 26 | end 27 | -------------------------------------------------------------------------------- /lib/shopify_app/user_tokens.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.UserTokens do 2 | use ShopifyApp.Repo, define_types: ShopifyApp.Schema.UserToken.t() 3 | 4 | require Ecto.Query 5 | require Logger 6 | 7 | alias ShopifyApp.Repo 8 | alias ShopifyApp.Schema 9 | alias ShopifyApp.UserTokens.Query 10 | 11 | @spec all() :: list(t()) 12 | @spec all(String.t()) :: list(t()) 13 | def all(myshopify_domain), 14 | do: Query.from() |> Query.where_myshopify_domain(myshopify_domain) |> Repo.all() 15 | 16 | def all, do: Repo.all(Query.from()) 17 | 18 | def upsert(params) do 19 | %Schema.UserToken{} 20 | |> Schema.UserToken.changeset(params) 21 | |> Repo.insert(on_conflict: :replace_all, conflict_target: [:associated_user_id]) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/shopify_app/user_tokens/query.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.UserTokens.Query do 2 | @moduledoc """ 3 | A module for querying user tokens. 4 | """ 5 | 6 | require Ecto.Query 7 | 8 | alias Ecto.Query 9 | 10 | alias ShopifyApp.Schema 11 | 12 | @type queryable() :: Ecto.Queryable.t() 13 | 14 | @spec from(Schema.UserToken) :: Ecto.Queryable.t() 15 | def from(query \\ Schema.UserToken) 16 | def from(Schema.UserToken), do: Query.from(u in Schema.UserToken, as: :user_token) 17 | 18 | @spec where_myshopify_domain(queryable(), String.t()) :: queryable() 19 | def where_myshopify_domain(query \\ from(), myshopify_domain), 20 | do: Query.where(query, [u], u.shop_myshopify_domain == ^myshopify_domain) 21 | end 22 | -------------------------------------------------------------------------------- /lib/shopify_app/webhook_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.WebhookHandler do 2 | @doc """ 3 | The Shopify Webhook Handler 4 | """ 5 | 6 | require Logger 7 | 8 | def handle_webhook(app, shop, myshopify_domain, payload) do 9 | Logger.debug( 10 | "incoming webhook #{inspect(app)} #{inspect(shop)} #{inspect(myshopify_domain)} #{inspect(payload)}" 11 | ) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/shopify_app/workers/install/create_webhook.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.Worker.Install.CreateWebhook do 2 | use Oban.Worker 3 | 4 | alias ShopifyApp.Shopify 5 | 6 | @impl true 7 | def perform(%{args: %{"myshopify_domain" => myshopify_domain, "webhook" => webhook}}) do 8 | {:ok, token} = ShopifyAPI.AuthTokenServer.get(myshopify_domain, ShopifyApp.Config.app_name()) 9 | Shopify.CreateWebhook.create(token, webhook) 10 | end 11 | 12 | def enqueue(myshopify_domain, webhook), 13 | do: %{myshopify_domain: myshopify_domain, webhook: webhook} |> new() |> Oban.insert() 14 | end 15 | -------------------------------------------------------------------------------- /lib/shopify_app/workflows/app_install.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.Workflow.AppInstall do 2 | require Logger 3 | 4 | alias ShopifyApp.Worker.Install.CreateWebhook 5 | 6 | @type t() :: %__MODULE__{myshopify_domain: String.t()} 7 | @enforce_keys [:myshopify_domain] 8 | defstruct @enforce_keys 9 | 10 | @spec call(t()) :: :ok 11 | def call(%{myshopify_domain: myshopify_domain} = context) when is_struct(context, __MODULE__) do 12 | Enum.each(ShopifyApp.Config.shop_webhooks(), &CreateWebhook.enqueue(myshopify_domain, &1)) 13 | 14 | Logger.debug("New install", myshopify_domain: myshopify_domain) 15 | :ok 16 | end 17 | 18 | @spec new(Keyword.t()) :: t() 19 | def new(params), do: struct(__MODULE__, params) 20 | end 21 | -------------------------------------------------------------------------------- /lib/shopify_app/workflows/app_uninstall.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyApp.Workflow.AppUninstall do 2 | @type t() :: %__MODULE__{myshopify_domain: String.t()} 3 | @enforce_keys [:myshopify_domain] 4 | defstruct @enforce_keys 5 | 6 | @spec call(t()) :: :ok 7 | def call(%{myshopify_domain: myshopify_domain} = context) when is_struct(context, __MODULE__) do 8 | {:ok, _} = myshopify_domain |> ShopifyApp.Shops.find() |> ShopifyApp.Shops.delete() 9 | :ok = ShopifyAPI.ShopServer.delete(myshopify_domain) 10 | :ok = ShopifyAPI.AuthTokenServer.delete(myshopify_domain, ShopifyApp.Config.app_name()) 11 | :ok = ShopifyAPI.UserTokenServer.delete_for_shop(myshopify_domain) 12 | end 13 | 14 | @spec new(Keyword.t()) :: t() 15 | def new(params), do: struct(__MODULE__, params) 16 | end 17 | -------------------------------------------------------------------------------- /lib/shopify_app_graphql/admin_schema.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAppGraphQL.AdminSchema do 2 | use Absinthe.Schema 3 | alias ShopifyAppGraphQL.Schema 4 | 5 | import_types(Schema.ShopTypes) 6 | 7 | query do 8 | import_fields(:shop_admin_queries) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/shopify_app_graphql/resolvers/shop.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAppGraphQL.Resolvers.Shop do 2 | alias ShopifyApp.Shops 3 | 4 | def shop(_parent, _args, %{context: context}) do 5 | {:ok, Shops.find(context.shop.domain)} 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/shopify_app_graphql/schema/shop_types.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAppGraphQL.Schema.ShopTypes do 2 | use Absinthe.Schema.Notation 3 | 4 | alias ShopifyAppGraphQL.Resolvers 5 | 6 | @desc "A shop" 7 | object :shop do 8 | field :myshopify_domain, :string 9 | end 10 | 11 | object :shop_admin_queries do 12 | @desc "Get the Shop" 13 | field :shop, :shop do 14 | resolve(&Resolvers.Shop.shop/3) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/shopify_app_web.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAppWeb do 2 | @moduledoc """ 3 | The entrypoint for defining your web interface, such 4 | as controllers, components, channels, and so on. 5 | 6 | This can be used in your application as: 7 | 8 | use ShopifyAppWeb, :controller 9 | use ShopifyAppWeb, :html 10 | 11 | The definitions below will be executed for every controller, 12 | component, etc, so keep them short and clean, focused 13 | on imports, uses and aliases. 14 | 15 | Do NOT define functions inside the quoted expressions 16 | below. Instead, define additional modules and import 17 | those modules here. 18 | """ 19 | 20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt) 21 | 22 | def router do 23 | quote do 24 | use Phoenix.Router, helpers: false 25 | 26 | # Import common connection and controller functions to use in pipelines 27 | import Plug.Conn 28 | import Phoenix.Controller 29 | import Phoenix.LiveView.Router 30 | end 31 | end 32 | 33 | def channel do 34 | quote do 35 | use Phoenix.Channel 36 | end 37 | end 38 | 39 | def controller do 40 | quote do 41 | use Phoenix.Controller, 42 | formats: [:html, :json], 43 | layouts: [html: ShopifyAppWeb.Layouts] 44 | 45 | use Gettext, backend: ShopifyAppWeb.Gettext 46 | 47 | import Plug.Conn 48 | 49 | unquote(verified_routes()) 50 | end 51 | end 52 | 53 | def live_view do 54 | quote do 55 | use Phoenix.LiveView, 56 | layout: {ShopifyAppWeb.Layouts, :app} 57 | 58 | unquote(html_helpers()) 59 | end 60 | end 61 | 62 | def live_component do 63 | quote do 64 | use Phoenix.LiveComponent 65 | 66 | unquote(html_helpers()) 67 | end 68 | end 69 | 70 | def html do 71 | quote do 72 | use Phoenix.Component 73 | 74 | # Import convenience functions from controllers 75 | import Phoenix.Controller, 76 | only: [get_csrf_token: 0, view_module: 1, view_template: 1] 77 | 78 | # Include general helpers for rendering HTML 79 | unquote(html_helpers()) 80 | end 81 | end 82 | 83 | defp html_helpers do 84 | quote do 85 | use Gettext, backend: ShopifyAppWeb.Gettext 86 | 87 | # HTML escaping functionality 88 | import Phoenix.HTML 89 | # Core UI components and translation 90 | import ShopifyAppWeb.CoreComponents 91 | 92 | # Shortcut for generating JS commands 93 | alias Phoenix.LiveView.JS 94 | 95 | # Routes generation with the ~p sigil 96 | unquote(verified_routes()) 97 | end 98 | end 99 | 100 | def verified_routes do 101 | quote do 102 | use Phoenix.VerifiedRoutes, 103 | endpoint: ShopifyAppWeb.Endpoint, 104 | router: ShopifyAppWeb.Router, 105 | statics: ShopifyAppWeb.static_paths() 106 | end 107 | end 108 | 109 | @doc """ 110 | When used, dispatch to the appropriate controller/view/etc. 111 | """ 112 | defmacro __using__(which) when is_atom(which) do 113 | apply(__MODULE__, which, []) 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/shopify_app_web/components/core_components.ex: -------------------------------------------------------------------------------- 1 | defmodule ShopifyAppWeb.CoreComponents do 2 | @moduledoc """ 3 | Provides core UI components. 4 | 5 | The components in this module use Tailwind CSS, a utility-first CSS framework. 6 | See the [Tailwind CSS documentation](https://tailwindcss.com) to learn how to 7 | customize the generated components in this module. 8 | 9 | Icons are provided by [heroicons](https://heroicons.com), using the 10 | [heroicons_elixir](https://github.com/mveytsman/heroicons_elixir) project. 11 | """ 12 | use Phoenix.Component 13 | use Gettext, backend: ShopifyAppWeb.Gettext 14 | 15 | alias Phoenix.LiveView.JS 16 | 17 | @doc """ 18 | Renders a modal. 19 | 20 | ## Examples 21 | 22 | <.modal id="confirm-modal"> 23 | Are you sure? 24 | <:confirm>OK 25 | <:cancel>Cancel 26 | 27 | 28 | JS commands may be passed to the `:on_cancel` and `on_confirm` attributes 29 | for the caller to react to each button press, for example: 30 | 31 | <.modal id="confirm" on_confirm={JS.push("delete")} on_cancel={JS.navigate(~p"/posts")}> 32 | Are you sure you? 33 | <:confirm>OK 34 | <:cancel>Cancel 35 | 36 | """ 37 | attr :id, :string, required: true 38 | attr :show, :boolean, default: false 39 | attr :on_cancel, JS, default: %JS{} 40 | attr :on_confirm, JS, default: %JS{} 41 | 42 | slot :inner_block, required: true 43 | slot :title 44 | slot :subtitle 45 | slot :confirm 46 | slot :cancel 47 | 48 | def modal(assigns) do 49 | ~H""" 50 |