From 5f3c7a94ae01aff632d82c7b995e6598042f70da Mon Sep 17 00:00:00 2001 From: Brad Hover Date: Mon, 30 Sep 2024 11:42:08 -0700 Subject: [PATCH 1/3] third batch of REST to GraphQL conversions for products and variants --- .../README.md | 10 +- .../script.liquid | 192 ++++++++++++------ .../auto-invite-customers-after-an-order.json | 4 +- 3 files changed, 143 insertions(+), 63 deletions(-) diff --git a/docs/auto-invite-customers-after-an-order/README.md b/docs/auto-invite-customers-after-an-order/README.md index 131883b5..960b0e00 100644 --- a/docs/auto-invite-customers-after-an-order/README.md +++ b/docs/auto-invite-customers-after-an-order/README.md @@ -2,7 +2,7 @@ Tags: Email, Invite, Orders, Retention -Automatically prompt customers to activate their customer accounts, after placing an order in your store, by triggering a customizable Shopify-powered email. Useful if your online store unlocks special offers, functionality, or content after making a purchase. Optionally, only send invitations if the customer has ordered a product with a specific tag. +Automatically prompt customers to activate their customer accounts, after placing an order in your store, by triggering a customizable Shopify-powered email. Useful if your online store unlocks special offers, functionality, or content after making a purchase. Optionally, only send invitations if the customer has a certain tag and/or has ordered a product with a specific tag. * View in the task library: [tasks.mechanic.dev/auto-invite-customers-after-an-order](https://tasks.mechanic.dev/auto-invite-customers-after-an-order) * Task JSON, for direct import: [task.json](../../tasks/auto-invite-customers-after-an-order.json) @@ -33,9 +33,13 @@ shopify/orders/create ## Documentation -Automatically prompt customers to activate their customer accounts, after placing an order in your store, by triggering a customizable Shopify-powered email. Useful if your online store unlocks special offers, functionality, or content after making a purchase. Optionally, only send invitations if the customer has ordered a product with a specific tag. +Automatically prompt customers to activate their customer accounts, after placing an order in your store, by triggering a customizable Shopify-powered email. Useful if your online store unlocks special offers, functionality, or content after making a purchase. Optionally, only send invitations if the customer has a certain tag and/or has ordered a product with a specific tag. + +This task works by asking Shopify to send along an invitation email, using the subject and body that you configure here. The email will use your Shopify account's "Customer account invite" email template, available in the "Notifications" area of your Shopify settings. + +**Note:** Because this task triggers a Shopify-powered email, and because this email already uses a Shopify template, the actual message body is optional. (If provided, HTML and CSS are not supported.) And, there's no need to add in an invitation link yourself – this will be taken care of by the Shopify email template as well. + -This task works by asking Shopify to send along an invitation email, using the subject and body that you configure here. The email will use your Shopify account's "Customer account invite" email template, available in the "Notifications" area of your Shopify settings. Note: Because this task triggers a Shopify-powered email, and because this email already uses a Shopify template, the actual message body is optional. (If provided, HTML and CSS are not supported.) And, there's no need to add in an invitation link yourself – this will be taken care of by the Shopify email template as well. ## Installing this task diff --git a/docs/auto-invite-customers-after-an-order/script.liquid b/docs/auto-invite-customers-after-an-order/script.liquid index 87c29a7b..089d151f 100644 --- a/docs/auto-invite-customers-after-an-order/script.liquid +++ b/docs/auto-invite-customers-after-an-order/script.liquid @@ -1,57 +1,115 @@ +{% assign avoid_duplicate_invites = options.only_invite_if_the_customer_has_not_yet_been_invited__boolean %} +{% assign invite_customer_tag = options.only_invite_if_the_customer_has_this_tag %} +{% assign invite_product_tag = options.only_invite_if_the_order_contains_a_product_with_this_tag %} +{% assign custom_invitation_email_subject = options.custom_invitation_email_subject %} +{% assign custom_invitation_email_message = options.custom_invitation_email_message__multiline %} +{% assign invitation_email_bcc = options.invitation_email_bcc__array %} + {% comment %} - Establish option order: - {{ options.only_invite_if_the_customer_has_not_yet_been_invited__boolean }} - {{ options.only_invite_if_the_customer_has_this_tag }} - {{ options.only_invite_if_the_order_contains_a_product_with_this_tag }} - {{ options.custom_invitation_email_subject }} - {{ options.custom_invitation_email_message__multiline }} - {{ options.invitation_email_bcc__array }} + -- get customer and product details from order {% endcomment %} -{% if event.preview %} - {% assign order = hash %} - {% assign order["customer"] = hash %} - {% assign order["customer"]["state"] = "disabled" %} +{% capture query %} + query { + order(id: {{ order.admin_graphql_api_id | json }}) { + id + name + customer { + id + displayName + email + state + tags + } + lineItems(first: 250) { + nodes { + title + product { + tags + } + } + } + } + } +{% endcapture %} - {% if options.only_invite_if_the_customer_has_this_tag != blank %} - {% assign order["customer"]["tags"] = options.only_invite_if_the_customer_has_this_tag %} - {% endif %} +{% assign result = query | shopify %} - {% if options.only_invite_if_the_order_contains_a_product_with_this_tag != blank %} - {% capture line_items_json %} - [ - { - "product": { - "tags": {{ options.only_invite_if_the_order_contains_a_product_with_this_tag | json }} +{% if event.preview %} + {% capture result_json %} + { + "data": { + "order": { + "id": "gid://shopify/Order/1234567890", + "name": "#1185", + "customer": { + "id": "gid://shopify/Customer/1234567890", + "state": "DISABLED", + "tags": {{ invite_customer_tag | json }} + }, + "lineItems": { + "nodes": [ + { + "product": { + "tags": {{ invite_product_tag | json }} + } + } + ] } } - ] - {% endcapture %} - {% assign order["line_items"] = line_items_json | parse_json %} - {% endif %} + } + } + {% endcapture %} + + {% assign result = result_json | parse_json %} {% endif %} +{% assign order = result.data.order %} +{% assign customer = order.customer %} + +{% comment %} + -- verify whether the customer qualifies for an invite based on configured options +{% endcomment %} + {% assign customer_qualifies = true %} -{% if order.customer == nil or order.customer.state == "enabled" %} + +{% if customer == blank or customer.state == "ENABLED" %} {% assign customer_qualifies = false %} -{% elsif options.only_invite_if_the_customer_has_not_yet_been_invited__boolean and order.customer.state == "invited" %} + +{% elsif avoid_duplicate_invites and customer.state == "INVITED" %} {% assign customer_qualifies = false %} -{% elsif options.only_invite_if_the_customer_has_this_tag != blank %} - {% assign customer_tags = order.customer.tags | downcase | split: ", " %} - {% assign tag_to_match = options.only_invite_if_the_customer_has_this_tag | strip | downcase %} + +{% elsif invite_customer_tag != blank %} + {% assign customer_tags + = customer.tags + | json + | downcase + | parse_json + %} + {% assign tag_to_match = invite_customer_tag | strip | downcase %} + {% unless customer_tags contains tag_to_match %} {% assign customer_qualifies = false %} {% endunless %} {% endif %} {% assign order_qualifies = false %} + {% if customer_qualifies %} - {% if options.only_invite_if_the_order_contains_a_product_with_this_tag == blank %} + {% if invite_product_tag == blank %} {% assign order_qualifies = true %} + {% else %} - {% for line_item in order.line_items %} - {% assign product_tags = line_item.product.tags | split: ", " %} - {% if product_tags contains options.only_invite_if_the_order_contains_a_product_with_this_tag %} + {% for line_item in order.lineItems.nodes %} + {% assign product_tags + = line_item.product.tags + | json + | downcase + | parse_json + %} + {% assign tag_to_match = invite_product_tag | strip | downcase %} + + {% if product_tags contains tag_to_match %} {% assign order_qualifies = true %} {% break %} {% endif %} @@ -59,36 +117,54 @@ {% endif %} {% endif %} -{% if order.customer == nil %} +{% if customer == blank %} {% log message: "Order does not have a customer; skipping invitation" %} -{% elsif customer_qualifies == false %} - {% log message: "Customer does not qualify for an invitation", customer_state: order.customer.state, customer_tags: order.customer.tags %} -{% elsif order_qualifies == false and options.only_invite_if_the_order_contains_a_product_with_this_tag != blank %} - {% log message: "No qualifying product found", required_product_tag: options.only_invite_if_the_order_contains_a_product_with_this_tag %} -{% endif %} - -{% if order_qualifies %} - {% assign customer_invite = hash %} - - {% if options.custom_invitation_email_subject != blank %} - {% assign customer_invite["subject"] = options.custom_invitation_email_subject %} - {% endif %} - {% if options.custom_invitation_email_message__multiline != blank %} - {% assign customer_invite["custom_message"] = options.custom_invitation_email_message__multiline %} - {% endif %} +{% elsif customer_qualifies == false %} + {% log + message: "Customer does not qualify for an invitation", + customer_name: customer.displayName, + customer_state: customer.state, + customer_tags: customer.tags + %} - {% if options.invitation_email_bcc__array != blank %} - {% assign customer_invite["bcc"] = options.invitation_email_bcc__array %} - {% endif %} +{% elsif order_qualifies == false and invite_product_tag != blank %} + {% log + message: "No qualifying product found", + invite_product_tag: invite_product_tag, + order: order + %} +{% endif %} +{% if order_qualifies %} {% action "shopify" %} - [ - "post", - "/admin/customers/{{ order.customer.id | json }}/send_invite.json", - { - "customer_invite": {{ customer_invite | json }} + mutation { + customerSendAccountInviteEmail( + customerId: {{ customer.id | json }} + email: { + {% if custom_invitation_email_subject != blank %} + subject: {{ custom_invitation_email_subject | json }} + {% endif %} + {% if custom_invitation_email_message != blank %} + customMessage: {{ custom_invitation_email_message | json }} + {% endif %} + {% if invitation_email_bcc != blank %} + bcc: {{ invitation_email_bcc | json }} + {% endif %} + } + ) { + customer { + id + displayName + email + state + } + userErrors { + code + field + message + } } - ] + } {% endaction %} {% endif %} diff --git a/tasks/auto-invite-customers-after-an-order.json b/tasks/auto-invite-customers-after-an-order.json index 28cf1cc2..1a0ace94 100644 --- a/tasks/auto-invite-customers-after-an-order.json +++ b/tasks/auto-invite-customers-after-an-order.json @@ -1,5 +1,5 @@ { - "docs": "Automatically prompt customers to activate their customer accounts, after placing an order in your store, by triggering a customizable Shopify-powered email. Useful if your online store unlocks special offers, functionality, or content after making a purchase. Optionally, only send invitations if the customer has ordered a product with a specific tag.\n\nThis task works by asking Shopify to send along an invitation email, using the subject and body that you configure here. The email will use your Shopify account's \"Customer account invite\" email template, available in the \"Notifications\" area of your Shopify settings. Note: Because this task triggers a Shopify-powered email, and because this email already uses a Shopify template, the actual message body is optional. (If provided, HTML and CSS are not supported.) And, there's no need to add in an invitation link yourself – this will be taken care of by the Shopify email template as well.", + "docs": "Automatically prompt customers to activate their customer accounts, after placing an order in your store, by triggering a customizable Shopify-powered email. Useful if your online store unlocks special offers, functionality, or content after making a purchase. Optionally, only send invitations if the customer has a certain tag and/or has ordered a product with a specific tag.\n\nThis task works by asking Shopify to send along an invitation email, using the subject and body that you configure here. The email will use your Shopify account's \"Customer account invite\" email template, available in the \"Notifications\" area of your Shopify settings.\n\n**Note:** Because this task triggers a Shopify-powered email, and because this email already uses a Shopify template, the actual message body is optional. (If provided, HTML and CSS are not supported.) And, there's no need to add in an invitation link yourself – this will be taken care of by the Shopify email template as well.\n\n", "halt_action_run_sequence_on_error": false, "name": "Auto-invite customers after an order", "online_store_javascript": null, @@ -14,7 +14,7 @@ "order_status_javascript": null, "perform_action_runs_in_sequence": false, "preview_event_definitions": [], - "script": "{% comment %}\n Establish option order:\n {{ options.only_invite_if_the_customer_has_not_yet_been_invited__boolean }}\n {{ options.only_invite_if_the_customer_has_this_tag }}\n {{ options.only_invite_if_the_order_contains_a_product_with_this_tag }}\n {{ options.custom_invitation_email_subject }}\n {{ options.custom_invitation_email_message__multiline }}\n {{ options.invitation_email_bcc__array }}\n{% endcomment %}\n\n{% if event.preview %}\n {% assign order = hash %}\n {% assign order[\"customer\"] = hash %}\n {% assign order[\"customer\"][\"state\"] = \"disabled\" %}\n\n {% if options.only_invite_if_the_customer_has_this_tag != blank %}\n {% assign order[\"customer\"][\"tags\"] = options.only_invite_if_the_customer_has_this_tag %}\n {% endif %}\n\n {% if options.only_invite_if_the_order_contains_a_product_with_this_tag != blank %}\n {% capture line_items_json %}\n [\n {\n \"product\": {\n \"tags\": {{ options.only_invite_if_the_order_contains_a_product_with_this_tag | json }}\n }\n }\n ]\n {% endcapture %}\n {% assign order[\"line_items\"] = line_items_json | parse_json %}\n {% endif %}\n{% endif %}\n\n{% assign customer_qualifies = true %}\n{% if order.customer == nil or order.customer.state == \"enabled\" %}\n {% assign customer_qualifies = false %}\n{% elsif options.only_invite_if_the_customer_has_not_yet_been_invited__boolean and order.customer.state == \"invited\" %}\n {% assign customer_qualifies = false %}\n{% elsif options.only_invite_if_the_customer_has_this_tag != blank %}\n {% assign customer_tags = order.customer.tags | downcase | split: \", \" %}\n {% assign tag_to_match = options.only_invite_if_the_customer_has_this_tag | strip | downcase %}\n {% unless customer_tags contains tag_to_match %}\n {% assign customer_qualifies = false %}\n {% endunless %}\n{% endif %}\n\n{% assign order_qualifies = false %}\n{% if customer_qualifies %}\n {% if options.only_invite_if_the_order_contains_a_product_with_this_tag == blank %}\n {% assign order_qualifies = true %}\n {% else %}\n {% for line_item in order.line_items %}\n {% assign product_tags = line_item.product.tags | split: \", \" %}\n {% if product_tags contains options.only_invite_if_the_order_contains_a_product_with_this_tag %}\n {% assign order_qualifies = true %}\n {% break %}\n {% endif %}\n {% endfor %}\n {% endif %}\n{% endif %}\n\n{% if order.customer == nil %}\n {% log message: \"Order does not have a customer; skipping invitation\" %}\n{% elsif customer_qualifies == false %}\n {% log message: \"Customer does not qualify for an invitation\", customer_state: order.customer.state, customer_tags: order.customer.tags %}\n{% elsif order_qualifies == false and options.only_invite_if_the_order_contains_a_product_with_this_tag != blank %}\n {% log message: \"No qualifying product found\", required_product_tag: options.only_invite_if_the_order_contains_a_product_with_this_tag %}\n{% endif %} \n\n{% if order_qualifies %}\n {% assign customer_invite = hash %}\n\n {% if options.custom_invitation_email_subject != blank %}\n {% assign customer_invite[\"subject\"] = options.custom_invitation_email_subject %}\n {% endif %}\n\n {% if options.custom_invitation_email_message__multiline != blank %}\n {% assign customer_invite[\"custom_message\"] = options.custom_invitation_email_message__multiline %}\n {% endif %}\n\n {% if options.invitation_email_bcc__array != blank %}\n {% assign customer_invite[\"bcc\"] = options.invitation_email_bcc__array %}\n {% endif %}\n\n {% action \"shopify\" %}\n [\n \"post\",\n \"/admin/customers/{{ order.customer.id | json }}/send_invite.json\",\n {\n \"customer_invite\": {{ customer_invite | json }}\n }\n ]\n {% endaction %}\n{% endif %}", + "script": "{% assign avoid_duplicate_invites = options.only_invite_if_the_customer_has_not_yet_been_invited__boolean %}\n{% assign invite_customer_tag = options.only_invite_if_the_customer_has_this_tag %}\n{% assign invite_product_tag = options.only_invite_if_the_order_contains_a_product_with_this_tag %}\n{% assign custom_invitation_email_subject = options.custom_invitation_email_subject %}\n{% assign custom_invitation_email_message = options.custom_invitation_email_message__multiline %}\n{% assign invitation_email_bcc = options.invitation_email_bcc__array %}\n\n{% comment %}\n -- get customer and product details from order\n{% endcomment %}\n\n{% capture query %}\n query {\n order(id: {{ order.admin_graphql_api_id | json }}) {\n id\n name\n customer {\n id\n displayName\n email\n state\n tags\n }\n lineItems(first: 250) {\n nodes {\n title\n product {\n tags\n }\n }\n }\n }\n }\n{% endcapture %}\n\n{% assign result = query | shopify %}\n\n{% if event.preview %}\n {% capture result_json %}\n {\n \"data\": {\n \"order\": {\n \"id\": \"gid://shopify/Order/1234567890\",\n \"name\": \"#1185\",\n \"customer\": {\n \"id\": \"gid://shopify/Customer/1234567890\",\n \"state\": \"DISABLED\",\n \"tags\": {{ invite_customer_tag | json }}\n },\n \"lineItems\": {\n \"nodes\": [\n {\n \"product\": {\n \"tags\": {{ invite_product_tag | json }}\n }\n }\n ]\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = result_json | parse_json %}\n{% endif %}\n\n{% assign order = result.data.order %}\n{% assign customer = order.customer %}\n\n{% comment %}\n -- verify whether the customer qualifies for an invite based on configured options\n{% endcomment %}\n\n{% assign customer_qualifies = true %}\n\n{% if customer == blank or customer.state == \"ENABLED\" %}\n {% assign customer_qualifies = false %}\n\n{% elsif avoid_duplicate_invites and customer.state == \"INVITED\" %}\n {% assign customer_qualifies = false %}\n\n{% elsif invite_customer_tag != blank %}\n {% assign customer_tags\n = customer.tags\n | json\n | downcase\n | parse_json\n %}\n {% assign tag_to_match = invite_customer_tag | strip | downcase %}\n\n {% unless customer_tags contains tag_to_match %}\n {% assign customer_qualifies = false %}\n {% endunless %}\n{% endif %}\n\n{% assign order_qualifies = false %}\n\n{% if customer_qualifies %}\n {% if invite_product_tag == blank %}\n {% assign order_qualifies = true %}\n\n {% else %}\n {% for line_item in order.lineItems.nodes %}\n {% assign product_tags\n = line_item.product.tags\n | json\n | downcase\n | parse_json\n %}\n {% assign tag_to_match = invite_product_tag | strip | downcase %}\n\n {% if product_tags contains tag_to_match %}\n {% assign order_qualifies = true %}\n {% break %}\n {% endif %}\n {% endfor %}\n {% endif %}\n{% endif %}\n\n{% if customer == blank %}\n {% log message: \"Order does not have a customer; skipping invitation\" %}\n\n{% elsif customer_qualifies == false %}\n {% log\n message: \"Customer does not qualify for an invitation\",\n customer_name: customer.displayName,\n customer_state: customer.state,\n customer_tags: customer.tags\n %}\n\n{% elsif order_qualifies == false and invite_product_tag != blank %}\n {% log\n message: \"No qualifying product found\",\n invite_product_tag: invite_product_tag,\n order: order\n %}\n{% endif %}\n\n{% if order_qualifies %}\n {% action \"shopify\" %}\n mutation {\n customerSendAccountInviteEmail(\n customerId: {{ customer.id | json }}\n email: {\n {% if custom_invitation_email_subject != blank %}\n subject: {{ custom_invitation_email_subject | json }}\n {% endif %}\n {% if custom_invitation_email_message != blank %}\n customMessage: {{ custom_invitation_email_message | json }}\n {% endif %}\n {% if invitation_email_bcc != blank %}\n bcc: {{ invitation_email_bcc | json }}\n {% endif %}\n }\n ) {\n customer {\n id\n displayName\n email\n state\n }\n userErrors {\n code\n field\n message\n }\n }\n }\n {% endaction %}\n{% endif %}\n", "subscriptions": [ "shopify/orders/create" ], From 94293dbb069af7a508b328445208676e5819af93 Mon Sep 17 00:00:00 2001 From: Brad Hover Date: Fri, 4 Oct 2024 01:09:29 -0700 Subject: [PATCH 2/3] use GraphQL for line item image query --- docs/abandoned-checkout-emails/README.md | 4 +- docs/abandoned-checkout-emails/script.liquid | 72 ++++++++++++++++++-- tasks/abandoned-checkout-emails.json | 4 +- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/docs/abandoned-checkout-emails/README.md b/docs/abandoned-checkout-emails/README.md index b50c5662..98d6ccf5 100644 --- a/docs/abandoned-checkout-emails/README.md +++ b/docs/abandoned-checkout-emails/README.md @@ -2,7 +2,7 @@ Tags: Abandoned Checkout, Email, Retention -This task monitors checkouts in your store, and kicks off an email a configurable number of hours after a checkout is created – if the checkout wasn't completed, and if the customer provided their email address. +This task monitors checkouts in your store, and kicks off an email a configurable number of hours after a checkout is created - if the checkout wasn't completed, and if the customer provided their email address. * View in the task library: [tasks.mechanic.dev/abandoned-checkout-emails](https://tasks.mechanic.dev/abandoned-checkout-emails) * Task JSON, for direct import: [task.json](../../tasks/abandoned-checkout-emails.json) @@ -36,7 +36,7 @@ shopify/orders/create ## Documentation -This task monitors checkouts in your store, and kicks off an email a configurable number of hours after a checkout is created – if the checkout wasn't completed, and if the customer provided their email address. +This task monitors checkouts in your store, and kicks off an email a configurable number of hours after a checkout is created - if the checkout wasn't completed, and if the customer provided their email address. This task uses the same basic formatting as the standard Shopify abandoned checkout notification template. You may override the action button background with your own brand color, provided it is entered as an RGB hex value (e.g. #abc123). diff --git a/docs/abandoned-checkout-emails/script.liquid b/docs/abandoned-checkout-emails/script.liquid index 098882eb..5ae83dfc 100644 --- a/docs/abandoned-checkout-emails/script.liquid +++ b/docs/abandoned-checkout-emails/script.liquid @@ -84,18 +84,78 @@ {% assign line_item_output = array %} {% for line_item in checkout.line_items %} - {% assign product = shop.products[line_item.product_id] %} - {% assign variant = shop.variants[line_item.variant_id] %} - {% assign variant_image = product.images | where: "id", variant.image_id | first %} - {% assign image = variant_image | default: product.image %} + {% comment %} + -- query GraphQL variant and product resource to determine line item image to use + {% endcomment %} + + {% capture query %} + query { + productVariant(id: {{ line_item.variant_id | prepend: "gid://shopify/ProductVariant/" | json }}) { + image { + url(transform: { + maxWidth: 60 + maxHeight: 60 + scale: 3 + crop: CENTER + }) + } + product { + featuredMedia { + mediaContentType + ... on MediaImage { + image { + url(transform: { + maxWidth: 60 + maxHeight: 60 + scale: 3 + crop: CENTER + }) + } + } + } + media( + first: 1 + query: "media_type:image" + ) { + nodes { + ... on MediaImage { + image { + url(transform: { + maxWidth: 60 + maxHeight: 60 + scale: 3 + crop: CENTER + }) + } + } + } + } + } + } + } + {% endcapture %} + + {% assign result = query | shopify %} + + {% assign variant = result.data.productVariant %} + + {% comment %} + -- use image assigned to variant if it exists; else use the featured media for the product if it is an image; else use the first product image + {% endcomment %} + + {% assign image_url + = variant.image.url + | default: variant.product.featuredMedia.image.url + | default: variant.product.media.nodes.first.image.url + %} {%- capture line_item_html -%} \n \n \n {%- endcapture -%}\n\n {% assign line_item_output = line_item_output | push: line_item_html %}\n {% endfor %}\n\n {%- capture body -%}\n \n \n \n {{ subject }}\n \n \n \n \n \n
- {% if image %} - + {% if image_url %} + {% endif %} diff --git a/tasks/abandoned-checkout-emails.json b/tasks/abandoned-checkout-emails.json index e4e566b2..991babcc 100644 --- a/tasks/abandoned-checkout-emails.json +++ b/tasks/abandoned-checkout-emails.json @@ -1,5 +1,5 @@ { - "docs": "This task monitors checkouts in your store, and kicks off an email a configurable number of hours after a checkout is created – if the checkout wasn't completed, and if the customer provided their email address.\n\nThis task uses the same basic formatting as the standard Shopify abandoned checkout notification template. You may override the action button background with your own brand color, provided it is entered as an RGB hex value (e.g. #abc123).\n\nYou can configure the custom message to the customer, the action button text, and the item list header. And since the task will set an email header to the primary locale (language) of your shop, these may be set to your native shop language. Product and variant titles will automatically be output in the language they are listed in your shop.\n\nEven though they don't appear in the task preview, item images *will* appear in the sent emails.\n\n**Important**: When using this task, make sure that Shopify's abandoned checkout notifications are disabled to prevent sending duplicate emails to customers.", + "docs": "This task monitors checkouts in your store, and kicks off an email a configurable number of hours after a checkout is created - if the checkout wasn't completed, and if the customer provided their email address.\n\nThis task uses the same basic formatting as the standard Shopify abandoned checkout notification template. You may override the action button background with your own brand color, provided it is entered as an RGB hex value (e.g. #abc123).\n\nYou can configure the custom message to the customer, the action button text, and the item list header. And since the task will set an email header to the primary locale (language) of your shop, these may be set to your native shop language. Product and variant titles will automatically be output in the language they are listed in your shop.\n\nEven though they don't appear in the task preview, item images *will* appear in the sent emails.\n\n**Important**: When using this task, make sure that Shopify's abandoned checkout notifications are disabled to prevent sending duplicate emails to customers.", "halt_action_run_sequence_on_error": false, "name": "Abandoned checkout emails", "online_store_javascript": null, @@ -14,7 +14,7 @@ "order_status_javascript": null, "perform_action_runs_in_sequence": false, "preview_event_definitions": [], - "script": "{% assign subject = options.email_subject__required %}\n{% assign custom_message = options.custom_message__multiline_required %}\n{% assign action_button_text = options.action_button_text__required %}\n{% assign cart_items_header_text = options.cart_items_header_text__required %}\n{% assign primary_brand_color = options.primary_brand_color_as_hex_rgb | default: \"#1990c6\" %}\n\n{% assign abandoned_event = \"user/checkouts/abandoned\" %}\n\n{% if event.topic == \"shopify/checkouts/create\" or event.topic == \"shopify/checkouts/update\" %}\n\n {% if event.preview %}\n {% assign checkout = hash %}\n {% assign checkout[\"token\"] = \"00000000000000000000000000000000\" %}\n {% assign checkout[\"abandoned_checkout_url\"] = \"https://example.shop/checkouts/...\" %}\n {% endif %}\n\n {% comment %}\n When a checkout is created or updated, save it to the cache.\n {% endcomment %}\n\n {% capture checkout_cache_key %}checkout:{{ checkout.token }}{% endcapture %}\n\n {% action \"cache\", \"set\", checkout_cache_key, checkout %}\n\n {% if event.topic == \"shopify/checkouts/create\" %}\n {% comment %}\n ... and if we're looking at the create event, schedule our abandoned checkout followup.\n {% endcomment %}\n\n {% action \"event\" %}\n {\n \"topic\": {{ abandoned_event | json }},\n \"data\": {\n \"checkout_token\": {{ checkout.token | json }}\n }\n }\n {% endaction %}\n {% endif %}\n\n{% elsif event.topic == \"shopify/orders/create\" %}\n\n {% if event.preview %}\n {% assign order = hash %}\n {% assign order[\"name\"] = \"#1234\" %}\n {% assign order[\"checkout_token\"] = \"00000000000000000000000000000000\" %}\n {% endif %}\n\n {% comment %}\n When an order comes in, cache the order name associated with the checkout token. We'll use this\n to determine if a checkout made it all the way to an order, thus making the checkout *not* abandoned.\n {% endcomment %}\n\n {% if order.checkout_token != blank %}\n {% capture order_name_cache_key %}checkout_order_name:{{ order.checkout_token }}{% endcapture %}\n\n {% action \"cache\", \"set\", order_name_cache_key, order.name %}\n {% endif %}\n\n{% elsif event.topic == abandoned_event %}\n\n {% comment %}\n At this stage, our job is to check the cache and see if the checkout converted. If it did, we\n do nothing. If it didn't, and we have checkout data safely in the cache, we fire off an email.\n If somehow the checkout didn't convert but also didn't make it to the cache, we bail.\n {% endcomment %}\n\n {% assign checkout_token = event.data.checkout_token %}\n\n {% capture order_name_cache_key %}checkout_order_name:{{ checkout_token }}{% endcapture %}\n {% capture checkout_cache_key %}checkout:{{ checkout_token }}{% endcapture %}\n\n {% comment %}\n Make sure to render an email during event preview - Mechanic and the merchant both need to\n see what this email will look like.\n {% endcomment %}\n\n {% if cache[order_name_cache_key] and event.preview != true %}\n {% capture message %}Checkout converted as order {{ cache[order_name_cache_key] }} - not sending an email{% endcapture %}\n {% log message %}\n\n {% elsif cache[checkout_cache_key].email != blank or event.preview %}\n {% assign checkout = cache[checkout_cache_key] %}\n\n {% assign line_item_output = array %}\n\n {% for line_item in checkout.line_items %}\n {% assign product = shop.products[line_item.product_id] %}\n {% assign variant = shop.variants[line_item.variant_id] %}\n {% assign variant_image = product.images | where: \"id\", variant.image_id | first %}\n {% assign image = variant_image | default: product.image %}\n\n {%- capture line_item_html -%}\n
\n \n \n \n
\n {% if image %}\n \n {% endif %}\n \n {{ line_item.title }} × {{ line_item.quantity }}
\n\n {% if line_item.variant_title != 'Default Title' %}\n {{ line_item.variant_title }}
\n {% endif %}\n
\n
\n \n \n \n
\n \n \n \n \n
\n
\n \n \n \n \n
\n \n \n \n \n
\n

\n {{ shop.name }}\n

\n
\n
\n
\n
\n \n \n \n \n
\n
\n \n \n \n \n
\n

{{ custom_message | strip | newline_to_br }}

\n \n \n \n \n \n \n \n
 
\n \n \n \n \n
{{ action_button_text }}
\n
\n
\n
\n
\n \n \n \n \n
\n
\n \n \n \n \n
\n

{{ cart_items_header_text }}

\n
\n \n \n \n \n
\n \n {{ line_item_output | join: newline }}\n
\n
\n
\n
\n
\n \n \n {%- endcapture -%}\n\n {% action \"email\" %}\n {\n \"to\": {{ checkout.email | json }},\n \"subject\": {{ subject | json }},\n \"body\": {{ body | json }},\n \"reply_to\": {{ shop.customer_email | json }},\n \"from_display_name\": {{ shop.name | json }}\n }\n {% endaction %}\n\n {% else %}\n {% log \"Checkout did not convert to an order, but no email address was ever given - unable to send an email\" %}\n {% endif %}\n{% endif %}\n", + "script": "{% assign subject = options.email_subject__required %}\n{% assign custom_message = options.custom_message__multiline_required %}\n{% assign action_button_text = options.action_button_text__required %}\n{% assign cart_items_header_text = options.cart_items_header_text__required %}\n{% assign primary_brand_color = options.primary_brand_color_as_hex_rgb | default: \"#1990c6\" %}\n\n{% assign abandoned_event = \"user/checkouts/abandoned\" %}\n\n{% if event.topic == \"shopify/checkouts/create\" or event.topic == \"shopify/checkouts/update\" %}\n\n {% if event.preview %}\n {% assign checkout = hash %}\n {% assign checkout[\"token\"] = \"00000000000000000000000000000000\" %}\n {% assign checkout[\"abandoned_checkout_url\"] = \"https://example.shop/checkouts/...\" %}\n {% endif %}\n\n {% comment %}\n When a checkout is created or updated, save it to the cache.\n {% endcomment %}\n\n {% capture checkout_cache_key %}checkout:{{ checkout.token }}{% endcapture %}\n\n {% action \"cache\", \"set\", checkout_cache_key, checkout %}\n\n {% if event.topic == \"shopify/checkouts/create\" %}\n {% comment %}\n ... and if we're looking at the create event, schedule our abandoned checkout followup.\n {% endcomment %}\n\n {% action \"event\" %}\n {\n \"topic\": {{ abandoned_event | json }},\n \"data\": {\n \"checkout_token\": {{ checkout.token | json }}\n }\n }\n {% endaction %}\n {% endif %}\n\n{% elsif event.topic == \"shopify/orders/create\" %}\n\n {% if event.preview %}\n {% assign order = hash %}\n {% assign order[\"name\"] = \"#1234\" %}\n {% assign order[\"checkout_token\"] = \"00000000000000000000000000000000\" %}\n {% endif %}\n\n {% comment %}\n When an order comes in, cache the order name associated with the checkout token. We'll use this\n to determine if a checkout made it all the way to an order, thus making the checkout *not* abandoned.\n {% endcomment %}\n\n {% if order.checkout_token != blank %}\n {% capture order_name_cache_key %}checkout_order_name:{{ order.checkout_token }}{% endcapture %}\n\n {% action \"cache\", \"set\", order_name_cache_key, order.name %}\n {% endif %}\n\n{% elsif event.topic == abandoned_event %}\n\n {% comment %}\n At this stage, our job is to check the cache and see if the checkout converted. If it did, we\n do nothing. If it didn't, and we have checkout data safely in the cache, we fire off an email.\n If somehow the checkout didn't convert but also didn't make it to the cache, we bail.\n {% endcomment %}\n\n {% assign checkout_token = event.data.checkout_token %}\n\n {% capture order_name_cache_key %}checkout_order_name:{{ checkout_token }}{% endcapture %}\n {% capture checkout_cache_key %}checkout:{{ checkout_token }}{% endcapture %}\n\n {% comment %}\n Make sure to render an email during event preview - Mechanic and the merchant both need to\n see what this email will look like.\n {% endcomment %}\n\n {% if cache[order_name_cache_key] and event.preview != true %}\n {% capture message %}Checkout converted as order {{ cache[order_name_cache_key] }} - not sending an email{% endcapture %}\n {% log message %}\n\n {% elsif cache[checkout_cache_key].email != blank or event.preview %}\n {% assign checkout = cache[checkout_cache_key] %}\n\n {% assign line_item_output = array %}\n\n {% for line_item in checkout.line_items %}\n {% comment %}\n -- query GraphQL variant and product resource to determine line item image to use\n {% endcomment %}\n\n {% capture query %}\n query {\n productVariant(id: {{ line_item.variant_id | prepend: \"gid://shopify/ProductVariant/\" | json }}) {\n image {\n url(transform: {\n maxWidth: 60\n maxHeight: 60\n scale: 3\n crop: CENTER\n })\n }\n product {\n featuredMedia {\n mediaContentType\n ... on MediaImage {\n image {\n url(transform: {\n maxWidth: 60\n maxHeight: 60\n scale: 3\n crop: CENTER\n })\n }\n }\n }\n media(\n first: 1\n query: \"media_type:image\"\n ) {\n nodes {\n ... on MediaImage {\n image {\n url(transform: {\n maxWidth: 60\n maxHeight: 60\n scale: 3\n crop: CENTER\n })\n }\n }\n }\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% assign variant = result.data.productVariant %}\n\n {% comment %}\n -- use image assigned to variant if it exists; else use the featured media for the product if it is an image; else use the first product image\n {% endcomment %}\n\n {% assign image_url\n = variant.image.url\n | default: variant.product.featuredMedia.image.url\n | default: variant.product.media.nodes.first.image.url\n %}\n\n {%- capture line_item_html -%}\n \n \n \n \n \n
\n {% if image_url %}\n \n {% endif %}\n \n {{ line_item.title }} × {{ line_item.quantity }}
\n\n {% if line_item.variant_title != 'Default Title' %}\n {{ line_item.variant_title }}
\n {% endif %}\n
\n \n \n {%- endcapture -%}\n\n {% assign line_item_output = line_item_output | push: line_item_html %}\n {% endfor %}\n\n {%- capture body -%}\n \n \n \n {{ subject }}\n \n \n \n \n \n \n \n \n \n
\n \n \n \n \n
\n
\n \n \n \n \n
\n \n \n \n \n
\n

\n {{ shop.name }}\n

\n
\n
\n
\n
\n \n \n \n \n
\n
\n \n \n \n \n
\n

{{ custom_message | strip | newline_to_br }}

\n \n \n \n \n \n \n \n
 
\n \n \n \n \n
{{ action_button_text }}
\n
\n
\n
\n
\n \n \n \n \n
\n
\n \n \n \n \n
\n

{{ cart_items_header_text }}

\n
\n \n \n \n \n
\n \n {{ line_item_output | join: newline }}\n
\n
\n
\n
\n
\n \n \n {%- endcapture -%}\n\n {% action \"email\" %}\n {\n \"to\": {{ checkout.email | json }},\n \"subject\": {{ subject | json }},\n \"body\": {{ body | json }},\n \"reply_to\": {{ shop.customer_email | json }},\n \"from_display_name\": {{ shop.name | json }}\n }\n {% endaction %}\n\n {% else %}\n {% log \"Checkout did not convert to an order, but no email address was ever given - unable to send an email\" %}\n {% endif %}\n{% endif %}\n", "subscriptions": [ "user/checkouts/abandoned+24.hours", "shopify/checkouts/create", From 762702bdf24de94ecf8933248c6b5813b1b546e6 Mon Sep 17 00:00:00 2001 From: Brad Hover Date: Mon, 7 Oct 2024 23:40:44 -0700 Subject: [PATCH 3/3] third batch of REST to GraphQL conversions for products and variants --- docs/README.md | 8 +- .../script.liquid | 2 +- .../README.md | 22 +- .../script.liquid | 249 +++++++++++------ .../README.md | 3 +- .../script.liquid | 251 +++++++++++++----- .../script.liquid | 76 ++++-- .../script.liquid | 2 +- tasks/auto-fulfill-orders-when-tagged.json | 2 +- ...ort-collections-by-product-properties.json | 12 +- ...o-tag-products-that-are-missing-costs.json | 9 +- tasks/send-email-when-an-order-comes-in.json | 2 +- ...oduct-templates-based-on-product-tags.json | 2 +- 13 files changed, 460 insertions(+), 180 deletions(-) diff --git a/docs/README.md b/docs/README.md index 6889634e..5f5ab60f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -62,8 +62,8 @@ This directory is built automatically. Each task's documentation is generated fr * [Auto-remove a product tag x days after it's added](./auto-remove-a-product-tag-x-days-after-its-added) * [Auto-remove attributes on new orders after X minutes](./auto-remove-attributes-on-new-orders-after-x-minutes) * [Auto-reserve draft order items for X days](./auto-reserve-draft-order-items-for-x-days) +* [Auto-sort collections by a product property](./auto-sort-collections-by-product-properties) * [Auto-sort collections by inventory levels](./auto-sort-collections-by-inventory-levels) -* [Auto-sort collections by product properties](./auto-sort-collections-by-product-properties) * [Auto-tag a customer's first order](./auto-tag-a-customers-first-order) * [Auto-tag cancelled orders](./auto-tag-cancelled-orders) * [Auto-tag customers based on discount codes used](./auto-tag-customers-based-on-discount-codes-used) @@ -622,8 +622,8 @@ This directory is built automatically. Each task's documentation is generated fr * [Auto create collections by metafield values](./auto-create-collections-by-metafield-values) * [Auto-add products to a custom collection when tagged](./auto-add-products-to-a-custom-collection-when-tagged) * [Auto-create collections by product type or vendor](./auto-create-collections-by-product-type-or-vendor) +* [Auto-sort collections by a product property](./auto-sort-collections-by-product-properties) * [Auto-sort collections by inventory levels](./auto-sort-collections-by-inventory-levels) -* [Auto-sort collections by product properties](./auto-sort-collections-by-product-properties) * [Auto-tag orders by product collections](./auto-tag-orders-by-product-collections) * [Auto-tag products in a manual collection](./auto-tag-products-in-a-manual-collection) * [Delete the oldest x products from a specific collection](./delete-the-oldest-x-products-from-a-specific-collection) @@ -1353,7 +1353,7 @@ This directory is built automatically. Each task's documentation is generated fr * [Auto-publish new products](./auto-publish-new-products) * [Auto-publish products tagged with the current day](./auto-publish-products-tagged-with-the-current-day) * [Auto-reserve draft order items for X days](./auto-reserve-draft-order-items-for-x-days) -* [Auto-sort collections by product properties](./auto-sort-collections-by-product-properties) +* [Auto-sort collections by a product property](./auto-sort-collections-by-product-properties) * [Auto-tag customers when they purchase a matching product](./auto-tag-customers-upon-product-purchase) * [Auto-tag customers who purchase an item on sale](./auto-tag-customers-who-purchase-an-item-on-sale) * [Auto-tag customers with product tags from their order](./tag-customers-with-product-tags-from-their-order) @@ -1641,8 +1641,8 @@ This directory is built automatically. Each task's documentation is generated fr ### Sort +* [Auto-sort collections by a product property](./auto-sort-collections-by-product-properties) * [Auto-sort collections by inventory levels](./auto-sort-collections-by-inventory-levels) -* [Auto-sort collections by product properties](./auto-sort-collections-by-product-properties) * [Move out-of-stock products to the end of a collection](./move-out-of-stock-products-to-the-end-of-a-collection) ### Spend diff --git a/docs/auto-fulfill-orders-when-tagged/script.liquid b/docs/auto-fulfill-orders-when-tagged/script.liquid index 8e79b152..0e739c92 100644 --- a/docs/auto-fulfill-orders-when-tagged/script.liquid +++ b/docs/auto-fulfill-orders-when-tagged/script.liquid @@ -82,7 +82,7 @@ {% for keyval in fulfillment_order_ids_by_location_and_type %} {% action "shopify" %} mutation { - fulfillmentCreateV2( + fulfillmentCreate( fulfillment: { lineItemsByFulfillmentOrder: [ {% for fulfillment_order_id in keyval[1] %} diff --git a/docs/auto-sort-collections-by-product-properties/README.md b/docs/auto-sort-collections-by-product-properties/README.md index e1c12ce7..83335e38 100644 --- a/docs/auto-sort-collections-by-product-properties/README.md +++ b/docs/auto-sort-collections-by-product-properties/README.md @@ -1,8 +1,8 @@ -# Auto-sort collections by product properties +# Auto-sort collections by a product property Tags: Collections, Products, Sort -This task re-sorts your collections by any product property that you choose. Use the "Product property lookups" option to control what attribute the task "looks up". For example, using "published_at" will result in sorting by the date and time the product was published. Add more than one lookup to dive more deeply into product data: using the lookups "metafields", "store", "priority", and "value" will result in a collection sorted by the `store.priority` metafield value on each product. +This task re-sorts your collections by the product property, product metafield, or variant property that you choose. Use the "Product property" or "First variant property" options to control what attribute the task looks up. For example, using `publishedAt` in the "Product property" field will result in sorting by the date and time the product was published, while using `sku` in the "First variant property" field will result in sorting by the sku of the first variant of each product in the collection. Alternatively, enter a product metafield as "namespace.key" (e.g. `store.priority`), and the task will attempt to sort by the value of that metafield. * View in the task library: [tasks.mechanic.dev/auto-sort-collections-by-product-properties](https://tasks.mechanic.dev/auto-sort-collections-by-product-properties) * Task JSON, for direct import: [task.json](../../tasks/auto-sort-collections-by-product-properties.json) @@ -12,9 +12,9 @@ This task re-sorts your collections by any product property that you choose. Use ```json { - "product_property_lookups__array_required": [ - "published_at" - ], + "product_property": "publishedAt", + "product_metafield": "", + "first_variant_property": "", "only_sort_these_collections__array": null, "reverse_sort__boolean": false, "run_hourly__boolean": false, @@ -41,13 +41,17 @@ user/collection_sort/complete ## Documentation -This task re-sorts your collections by any product property that you choose. Use the "Product property lookups" option to control what attribute the task "looks up". For example, using "published_at" will result in sorting by the date and time the product was published. Add more than one lookup to dive more deeply into product data: using the lookups "metafields", "store", "priority", and "value" will result in a collection sorted by the `store.priority` metafield value on each product. +This task re-sorts your collections by the product property, product metafield, or variant property that you choose. Use the "Product property" or "First variant property" options to control what attribute the task looks up. For example, using `publishedAt` in the "Product property" field will result in sorting by the date and time the product was published, while using `sku` in the "First variant property" field will result in sorting by the sku of the first variant of each product in the collection. Alternatively, enter a product metafield as "namespace.key" (e.g. `store.priority`), and the task will attempt to sort by the value of that metafield. -Refer to [Shopify's API documentation](https://help.shopify.com/en/api/reference/products/product) to find the product property you're looking for. +Run this task manually to re-sort your collections on demand, or choose to run it hourly or nightly. This task will scan all collections in the shop on each run, unless you configure it to only sort certain collections using each collection's title, handle, or ID. Optionally, choose the "Reverse sort" option to have the results reversed, mainly useful for sorting by descending numeric values (e.g. `inventoryTotal`). -Run this task manually to re-sort your collections on demand, or choose to run it hourly or nightly. +**Important:** -This task will scan all collections in the shop on each run, unless you configure it to only sort certain collections using each collection's title or ID. The collections processed by this task must be configured for manual sorting, otherwise they will be ignored. [Learn how to change the sort order of your collections.](https://help.shopify.com/en/manual/products/collections/collection-layout#change-the-sort-order-for-the-products-in-a-collection) +- You may only choose one sorting method. +- The collections processed by this task must be configured for manual sorting, otherwise they will be ignored. +- The property you choose should be a `DateTime`, `Int`, or `String` field on the [GraphQL Product](https://shopify.dev/docs/api/admin-graphql/latest/objects/Product) or [GraphQL ProductVariant](https://shopify.dev/docs/api/admin-graphql/latest/objects/ProductVariant) resource, otherwise the task will generate an error during execution. For help migrating from prior versions of this task, Shopify has documented the mappings for [REST product properties](https://shopify.dev/docs/api/admin-rest/latest/resources/product) and [REST product variant properties](https://shopify.dev/docs/api/admin-rest/latest/resources/product-variant) to GraphQL. (e.g. REST: `published_at` => GraphQL: `publishedAt`) +- Products with values for a property/metafield will always be placed before products with no values. This rule applies even when the sort order is reversed. +- If a metafield is configured that does not exist, the task will just report that no moves are needed. No error will be thrown. ## Installing this task diff --git a/docs/auto-sort-collections-by-product-properties/script.liquid b/docs/auto-sort-collections-by-product-properties/script.liquid index 6a6f25fe..7a50a150 100644 --- a/docs/auto-sort-collections-by-product-properties/script.liquid +++ b/docs/auto-sort-collections-by-product-properties/script.liquid @@ -1,7 +1,54 @@ -{% assign product_property_lookups = options.product_property_lookups__array_required %} +{% assign product_property = options.product_property %} +{% assign product_metafield = options.product_metafield %} +{% assign first_variant_property = options.first_variant_property %} {% assign only_sort_these_collections = options.only_sort_these_collections__array %} {% assign reverse_sort = options.reverse_sort__boolean %} +{% comment %} + -- build the query fragment for the configured property +{% endcomment %} + +{% assign property_fields_count = 0 %} + +{% if product_property != blank %} + {% assign property_fields_count = property_fields_count | plus: 1 %} +{% endif %} + +{% if product_metafield != blank %} + {% assign property_fields_count = property_fields_count | plus: 1 %} +{% endif %} + +{% if first_variant_property != blank %} + {% assign property_fields_count = property_fields_count | plus: 1 %} +{% endif %} + +{% if property_fields_count != 1 %} + {% error "Configure one (and only one) of these options: 'Product property', 'Product metafield', or 'First variant property'" %} + {% break %} +{% endif %} + +{% assign query_fragment = nil %} + +{% if product_property != blank %} + {% assign query_fragment = product_property %} + +{% elsif product_metafield != blank %} + {%- capture query_fragment -%} + metafield(key: {{ product_metafield | json }}) { + value + } + {%- endcapture -%} + +{% elsif first_variant_property != blank %} + {%- capture query_fragment -%} + variants(first: 1) { + nodes { + {{ first_variant_property }} + } + } + {%- endcapture -%} +{% endif %} + {% if event.topic == "mechanic/user/trigger" or event.topic contains "mechanic/scheduler/" %} {% comment %} -- get IDs for all manually sorted collections, optionally restricted to specific collections by ID or title @@ -16,16 +63,17 @@ collections( first: 250 after: {{ cursor | json }} - query: {{ search_query | json }} ) { pageInfo { hasNextPage endCursor } nodes { + id legacyResourceId - sortOrder title + handle + sortOrder } } } @@ -40,6 +88,7 @@ "collections": { "nodes": [ { + "id": "gid://shopify/Collection/1234567890", "legacyResourceId": "1234567890", "sortOrder": "MANUAL" } @@ -52,10 +101,15 @@ {% assign result = result_json | parse_json %} {% endif %} + {% comment %} + -- loop through collections and filter by ID, title, or handle as configured + {% endcomment %} + {% for collection in result.data.collections.nodes %} {% if only_sort_these_collections != blank %} {% unless only_sort_these_collections contains collection.legacyResourceId or only_sort_these_collections contains collection.title + or only_sort_these_collections contains collection.handle or event.preview %} {% continue %} @@ -63,14 +117,13 @@ {% endif %} {% if collection.sortOrder != "MANUAL" %} - {% log - message: "Collection is not configured for manual sorting; skipping.", - collection: collection - %} + {% log %} + {{ collection.title | json | append: " is not configured for manual sorting; skipping." | json }} + {% endlog %} {% continue %} {% endif %} - {% assign collection_ids_to_sort = collection_ids_to_sort | push: collection.legacyResourceId %} + {% assign collection_ids_to_sort = collection_ids_to_sort | push: collection.id %} {% endfor %} {% if result.data.collections.pageInfo.hasNextPage %} @@ -81,7 +134,7 @@ {% endfor %} {% if collection_ids_to_sort == blank %} - {% log "No collections qualified to be sorted on this task run." %} + {% log "None of the configured collections were found or set for manual sorting." %} {% break %} {% endif %} @@ -114,95 +167,122 @@ {% assign cache_index = event.data.cache_index %} {% assign collection_ids_to_sort = cache[cache_key] %} {% assign collection_id = collection_ids_to_sort[cache_index] %} - {% assign collection = shop.collections[collection_id] %} - - {% if event.preview %} - {% comment %} - -- use stub data instead of a preview event to incorporate the configured product properties - {% endcomment %} - {% assign product_ids = array | push: "1234567890", "3456789012", "2345678901" %} - {% assign products = array %} + {% assign moves = array %} + {% assign product_ids_and_positions = hash %} + {% assign product_ids_and_values = array %} - {% for product_id in product_ids %} - {% assign product = hash %} + {% assign cursor = nil %} + {% assign products = array %} - {% for lookup in product_property_lookups reversed %} - {% if forloop.first %} - {% assign product[lookup] = product_id %} + {% for n in (1..100) %} + {% capture query %} + query { + collection(id: {{ collection_id | json }}) { + id + title + products( + first: 250 + after: {{ cursor | json }} + sortKey: COLLECTION_DEFAULT + ) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + {{ query_fragment }} + } + } + } + } + {% endcapture %} - {% else %} - {% assign temp = product %} - {% assign product = hash %} - {% assign product[lookup] = temp %} - {% endif %} - {% endfor %} + {% assign result = query | shopify %} - {% assign product["id"] = product_id %} - {% assign product["admin_graphql_api_id"] = "gid://shopify/Product/" | append: product_id %} - {% assign products[products.size] = product %} - {% endfor %} + {% if event.preview %} + {% capture result_json %} + { + "data": { + "collection": { + "id": "gid://shopify/Collection/1234567890", + "title": "Widgets", + "products": { + "nodes": [ + { + "id": "gid://shopify/Product/1234567890" + }, + { + "id": "gid://shopify/Product/2345678901" + } + ] + } + } + } + } + {% endcapture %} - {% capture collection_json %} - { - "title": {{ only_sort_these_collections[0] | default: "Some collection" | json }}, - "admin_graphql_api_id": "gid://shopify/Collection/1234567890", - "products": {{ products | json }} - } - {% endcapture %} + {% assign result = result_json | parse_json %} + {% endif %} - {% assign collection = collection_json | parse_json %} - {% endif %} + {% assign collection = result.data.collection %} + {% assign products = products | concat: result.data.collection.products.nodes %} - {% comment %} - -- process products using REST resources to align with configured product properties - {% endcomment %} + {% if result.data.products.pageInfo.hasNextPage %} + {% assign cursor = result.data.products.pageInfo.endCursor %} + {% else %} + {% break %} + {% endif %} + {% endfor %} - {% assign moves = array %} - {% assign product_ids_and_positions = hash %} - {% assign product_ids_and_values = array %} + {% log collection_products: products %} - {% for product in collection.products %} - {% assign product_ids_and_positions[product.admin_graphql_api_id] = forloop.index0 %} + {% for product in products %} + {% assign product_ids_and_positions[product.id] = forloop.index0 %} {% assign product_id_and_value = hash %} - {% assign product_id_and_value["id"] = product.admin_graphql_api_id %} - - {% assign value = product %} - - {% for lookup in options.product_property_lookups__array_required %} - {% comment %} - -- NOTE: command methods (size, first, last) can only be invoked via dot notation - {% endcomment %} - - {% case lookup %} - {% when "size" %} - {% assign value = value.size %} - {% when "first" %} - {% assign value = value.first %} - {% when "last" %} - {% assign value = value.last %} - {% else %} - {% assign value = value[lookup] %} - {% endcase %} - {% endfor %} + {% assign product_id_and_value["id"] = product.id %} + + {% if product_property != blank %} + {% assign value = product[product_property] %} + + {% elsif product_metafield != blank %} + {% assign value = product.metafield.value %} + + {% elsif first_variant_property != blank %} + {% assign value = product.variants.nodes.first[first_variant_property] %} + {% endif %} {% comment %} - -- make sure this is always a serializable/sortable object, defaulting to nil in the case of an empty string so that SKU values which are *either* nil or an empty string always end up at the end of the list. + -- make sure this is always a serializable/sortable object, defaulting to nil in the case of an empty string + -- values which are *either* nil or an empty string will always end up at the end of the list {% endcomment %} {% assign product_id_and_value["value"] = value | json | parse_json | default: nil %} - {% assign product_ids_and_values[product_ids_and_values.size] = product_id_and_value %} {% endfor %} {% assign sorted_product_values = product_ids_and_values | sort: "value" %} - {% assign sorted_product_ids = product_ids_and_values | sort: "value" | map: "id" %} + {% assign sorted_product_ids = sorted_product_values | map: "id" %} {% if reverse_sort %} - {% assign sorted_product_ids = sorted_product_ids | reverse %} + {% comment %} + -- only reverse the order of products which have values, leaving nil values at the end + {% endcomment %} + + {% assign sorted_product_ids + = sorted_product_values + | where: "value" + | map: "id" + | reverse + | concat: sorted_product_ids + | uniq + %} {% endif %} + {% log sorted_product_ids: sorted_product_ids %} + {% comment %} -- determine the moves necessary to place products in their sorted positions {% endcomment %} @@ -216,6 +296,27 @@ {% endif %} {% endfor %} + {% comment %} + -- add preview here so collectionReorderProducts is reached, and the proper scopes are requested + {% endcomment %} + + {% if event.preview %} + {% capture moves_json %} + [ + { + "id": "gid://shopify/Product/1234567890", + "newPosition": "1" + }, + { + "id": "gid://shopify/Product/2345678901", + "newPosition": "0" + } + ] + {% endcapture %} + + {% assign moves = moves_json | parse_json %} + {% endif %} + {% if moves == blank %} {% log message: "No moves necessary for this collection, everything is already in its appropriate sort order.", @@ -239,7 +340,7 @@ {% action "shopify" %} mutation { collectionReorderProducts( - id: {{ collection.admin_graphql_api_id | json }} + id: {{ collection.id | json }} moves: {{ move_group | graphql_arguments }} ) { userErrors { diff --git a/docs/auto-tag-products-that-are-missing-costs/README.md b/docs/auto-tag-products-that-are-missing-costs/README.md index 79e7e378..ca6f9c8f 100644 --- a/docs/auto-tag-products-that-are-missing-costs/README.md +++ b/docs/auto-tag-products-that-are-missing-costs/README.md @@ -21,9 +21,10 @@ Use this task to help you close in on the last few products that are missing cos ## Subscriptions ```liquid -mechanic/user/trigger shopify/products/create shopify/products/update +mechanic/user/trigger +mechanic/shopify/bulk_operation ``` [Learn about event subscriptions in Mechanic](https://learn.mechanic.dev/core/tasks/subscriptions) diff --git a/docs/auto-tag-products-that-are-missing-costs/script.liquid b/docs/auto-tag-products-that-are-missing-costs/script.liquid index f121704e..4e44c8af 100644 --- a/docs/auto-tag-products-that-are-missing-costs/script.liquid +++ b/docs/auto-tag-products-that-are-missing-costs/script.liquid @@ -1,10 +1,159 @@ -{% if event.preview %} +{% assign tag_for_cost_missing = options.tag_for_cost_missing__required %} + +{% if event.topic == "shopify/products/create" or event.topic == "shopify/products/update" %} + {% assign missing_cost = nil %} + {% assign cursor = nil %} + + {% comment %} + -- query costs of variant inventory items, breaking if one is found without a cost; support up to 2k variants + {% endcomment %} + + {% for n in (1..8) %} + {% capture query %} + query { + product(id: {{ product.admin_graphql_api_id | json }}) { + id + tags + variants( + first: 250 + after: {{ cursor | json }} + ) { + pageInfo { + hasNextPage + endCursor + } + nodes { + inventoryItem { + unitCost { + amount + } + } + } + } + } + } + {% endcapture %} + + {% assign result = query | shopify %} + + {% if event.preview %} + {% capture result_json %} + { + "data": { + "product": { + "id": "gid://shopify/Product/1234567890", + "variants": { + "nodes": [ + { + "inventoryItem": { + "unitCost": null + } + } + ] + } + + } + } + } + {% endcapture %} + + {% assign result = result_json | parse_json %} + {% endif %} + + {% assign product = result.data.product %} + + {% for variant in product.variants.nodes %} + {% if variant.inventoryItem.unitCost == blank %} + {% assign missing_cost = true %} + {% break %} + {% endif %} + {% endfor %} + + {% if missing_cost %} + {% break %} + {% endif %} + + {% if result.data.product.variants.pageInfo.hasNextPage %} + {% assign cursor = result.data.product.variants.pageInfo.endCursor %} + {% else %} + {% break %} + {% endif %} + {% endfor %} + + {% if missing_cost %} + {% unless product.tags contains tag_for_cost_missing %} + {% action "shopify" %} + mutation { + tagsAdd( + id: {{ product.id | json }} + tags: {{ tag_for_cost_missing | json }} + ) { + userErrors { + field + message + } + } + } + {% endaction %} + {% endunless %} + + {% elsif product.tags contains tag_for_cost_missing %} + {% action "shopify" %} + mutation { + tagsRemove( + id: {{ product.id | json }} + tags: {{ tag_for_cost_missing | json }} + ) { + userErrors { + field + message + } + } + } + {% endaction %} + {% endif %} + +{% elsif event.topic == "mechanic/user/trigger" %} + {% comment %} + -- query all products in the shop and their variant inventory items + {% endcomment %} + + {% capture bulk_operation_query %} + query { + products { + edges { + node { + __typename + id + tags + variants { + edges { + node { + __typename + id + inventoryItem { + unitCost { + amount + } + } + } + } + } + } + } + } + } + {% endcapture %} + {% action "shopify" %} mutation { - tagsAdd( - id: "gid://shopify/Product/1234567890" - tags: {{ options.tag_for_cost_missing__required | json }} + bulkOperationRunQuery( + query: {{ bulk_operation_query | json }} ) { + bulkOperation { + id + status + } userErrors { field message @@ -12,25 +161,48 @@ } } {% endaction %} -{% elsif event.topic == "mechanic/user/trigger" %} - {% for product in shop.products %} - {% assign missing_cost = true %} - {% for variant in product.variants %} - {% if variant.inventory_item.cost != blank %} - {% assign missing_cost = false %} + +{% elsif event.topic == "mechanic/shopify/bulk_operation" %} + {% if event.preview %} + {% capture jsonl_string %} + {"__typename":"Product","id":"gid://shopify/Product/1234567890"} + {"__typename":"ProductVariant","id":"gid://shopify/ProductVariant/1234567890","inventoryItem":{"unitCost":null},"__parentId":"gid://shopify/Product/1234567890"} + {% endcapture %} + + {% assign bulkOperation = hash %} + {% assign bulkOperation["objects"] = jsonl_string | parse_jsonl %} + {% endif %} + + {% assign products = bulkOperation.objects | where: "__typename", "Product" %} + {% assign bulk_variants = bulkOperation.objects | where: "__typename", "ProductVariant" %} + + {% comment %} + -- process all products returned by the bulk op query + {% endcomment %} + + {% for product in products %} + {% comment %} + -- link the variants to the product and check if any have a missing unitCost + {% endcomment %} + + {% assign variants = bulk_variants | where: "__parentId", product.id %} + + {% assign missing_cost = nil %} + + {% for variant in variants %} + {% if variant.inventoryItem.unitCost == blank %} + {% assign missing_cost = true %} {% break %} {% endif %} {% endfor %} - {% assign product_tags = product.tags | split: ", " %} - {% if missing_cost %} - {% unless product_tags contains options.tag_for_cost_missing__required %} + {% unless product.tags contains tag_for_cost_missing %} {% action "shopify" %} mutation { tagsAdd( - id: {{ product.admin_graphql_api_id | json }} - tags: {{ options.tag_for_cost_missing__required | json }} + id: {{ product.id | json }} + tags: {{ tag_for_cost_missing | json }} ) { userErrors { field @@ -40,12 +212,13 @@ } {% endaction %} {% endunless %} - {% elsif product_tags contains options.tag_for_cost_missing__required %} + + {% elsif product.tags contains tag_for_cost_missing %} {% action "shopify" %} mutation { tagsRemove( - id: {{ product.admin_graphql_api_id | json }} - tags: {{ options.tag_for_cost_missing__required | json }} + id: {{ product.id | json }} + tags: {{ tag_for_cost_missing | json }} ) { userErrors { field @@ -56,46 +229,4 @@ {% endaction %} {% endif %} {% endfor %} -{% elsif event.topic == "shopify/products/create" or event.topic == "shopify/products/update" %} - {% assign missing_cost = true %} - {% for variant in product.variants %} - {% if variant.inventory_item.cost != blank %} - {% assign missing_cost = false %} - {% break %} - {% endif %} - {% endfor %} - - {% assign product_tags = product.tags | split: ", " %} - - {% if missing_cost %} - {% unless product_tags contains options.tag_for_cost_missing__required %} - {% action "shopify" %} - mutation { - tagsAdd( - id: {{ product.admin_graphql_api_id | json }} - tags: {{ options.tag_for_cost_missing__required | json }} - ) { - userErrors { - field - message - } - } - } - {% endaction %} - {% endunless %} - {% elsif product_tags contains options.tag_for_cost_missing__required %} - {% action "shopify" %} - mutation { - tagsRemove( - id: {{ product.admin_graphql_api_id | json }} - tags: {{ options.tag_for_cost_missing__required | json }} - ) { - userErrors { - field - message - } - } - } - {% endaction %} - {% endif %} {% endif %} diff --git a/docs/send-email-when-an-order-comes-in/script.liquid b/docs/send-email-when-an-order-comes-in/script.liquid index 676e67ce..33f69c58 100644 --- a/docs/send-email-when-an-order-comes-in/script.liquid +++ b/docs/send-email-when-an-order-comes-in/script.liquid @@ -1,24 +1,66 @@ -{% assign order_qualifies = false %} -{% if options.only_for_orders_including_this_product_tag == blank %} +{% assign only_for_orders_including_this_product_tag = options.only_for_orders_including_this_product_tag %} +{% assign email_recipients = options.email_recipients__required %} +{% assign email_subject = options.email_subject__required %} +{% assign email_body = options.email_body__required_multiline %} + +{% if only_for_orders_including_this_product_tag == blank %} {% assign order_qualifies = true %} + {% else %} - {% assign order_product_tags = order.line_items | map: "product" | map: "tags" | join: ", " | split: ", " | sort | uniq %} - {% if order_product_tags contains options.only_for_orders_including_this_product_tag %} - {% assign order_qualifies = true %} + {% capture query %} + query { + order(id: {{ order.admin_graphql_api_id | json }}) { + lineItems(first: 250) { + nodes { + product { + tags + } + } + } + } + } + {% endcapture %} + + {% assign result = query | shopify %} + + {% if event.preview %} + {% capture result_json %} + { + "data": { + "order": { + "lineItems": { + "nodes": [ + { + "product": { + "tags": {{ only_for_orders_including_this_product_tag | json }} + } + } + ] + } + } + } + } + {% endcapture %} + + {% assign result = result_json | parse_json %} {% endif %} + + {% for line_item in result.data.order.lineItems.nodes %} + {% if line_item.product.tags contains only_for_orders_including_this_product_tag %} + {% assign order_qualifies = true %} + {% break %} + {% endif %} + {% endfor %} {% endif %} -{% if event.preview or order_qualifies %} - { - "action": { - "type": "email", - "options": { - "to": {{ options.email_recipients__required | json }}, - "subject": {{ options.email_subject__required | strip | json }}, - "body": {{ options.email_body__required_multiline | newline_to_br | strip | json }}, - "reply_to": {{ shop.customer_email | json }}, - "from_display_name": {{ shop.name | json }} - } +{% if order_qualifies %} + {% action "email" %} + { + "to": {{ email_recipients | json }}, + "subject": {{ email_subject | json }}, + "body": {{ email_body | newline_to_br | json }}, + "reply_to": {{ shop.customer_email | json }}, + "from_display_name": {{ shop.name | json }} } - } + {% endaction %} {% endif %} diff --git a/docs/set-product-templates-based-on-product-tags/script.liquid b/docs/set-product-templates-based-on-product-tags/script.liquid index 5d2bd0c1..8a1e0a52 100644 --- a/docs/set-product-templates-based-on-product-tags/script.liquid +++ b/docs/set-product-templates-based-on-product-tags/script.liquid @@ -85,7 +85,7 @@ {% action "shopify" %} mutation { productUpdate( - input: { + product: { id: {{ product.id | json }} templateSuffix: {{ product_template_suffix_to_apply | json }} } diff --git a/tasks/auto-fulfill-orders-when-tagged.json b/tasks/auto-fulfill-orders-when-tagged.json index 226a70a6..85cd5530 100644 --- a/tasks/auto-fulfill-orders-when-tagged.json +++ b/tasks/auto-fulfill-orders-when-tagged.json @@ -10,7 +10,7 @@ }, "order_status_javascript": null, "perform_action_runs_in_sequence": false, - "script": "{% capture query %}\n query {\n order(id: {{ order.admin_graphql_api_id | json }}) {\n id\n tags\n fulfillmentOrders(\n first: 20\n query: \"status:open\"\n ) {\n nodes {\n id\n assignedLocation {\n name\n }\n deliveryMethod {\n methodType\n }\n }\n }\n }\n }\n{% endcapture %}\n\n{% assign result = query | shopify %}\n\n{% if event.preview %}\n {% capture result_json %}\n {\n \"data\": {\n \"order\": {\n \"id\": \"gid://shopify/Order/1234567890\",\n \"tags\": {{ options.order_tag_to_watch_for__required | json }},\n \"fulfillmentOrders\": {\n \"nodes\": [\n {\n \"id\": \"gid://shopify/FulfillmentOrder/1234567890\",\n \"assignedLocation\": {\n \"name\": \"ACME Warehouse\"\n },\n \"deliveryMethod\": {\n \"methodType\": \"SHIPPING\"\n }\n }\n ]\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = result_json | parse_json %}\n{% endif %}\n\n{% assign order = result.data.order %}\n{% assign fulfillment_orders = order.fulfillmentOrders.nodes %}\n\n{% if fulfillment_orders != blank and order.tags contains options.order_tag_to_watch_for__required %}\n {% if options.ignore_if_this_order_tag_is_found != blank and order.tags contains options.ignore_if_this_order_tag_is_found %}\n {% log message: \"Order had the tag to watch for, but it also had the ignore tag. Skipping.\", order_tags: order.tags, task_options: options %}\n {% break %}\n {% endif %}\n\n {% comment %}\n Note: fulfillments cannot be created with fulfillment orders at different locations or delivery types; so separate them out\n {% endcomment %}\n\n {% assign fulfillment_order_ids_by_location_and_type = hash %}\n\n {% for fulfillment_order in fulfillment_orders %}\n {% assign location_and_type\n = fulfillment_order.assignedLocation.name\n | append: \"|\"\n | append: fulfillment_order.deliveryMethod.methodType\n %}\n {% assign fulfillment_order_ids_by_location_and_type[location_and_type]\n = fulfillment_order_ids_by_location_and_type[location_and_type]\n | default: array\n | push: fulfillment_order.id\n %}\n {% endfor %}\n\n {% for keyval in fulfillment_order_ids_by_location_and_type %}\n {% action \"shopify\" %}\n mutation {\n fulfillmentCreateV2(\n fulfillment: {\n lineItemsByFulfillmentOrder: [\n {% for fulfillment_order_id in keyval[1] %}\n { fulfillmentOrderId: {{ fulfillment_order_id | json }} }\n {% endfor %}\n ]\n notifyCustomer: {{ options.notify_customer_on_fulfillment__boolean | json }}\n }\n ) {\n fulfillment {\n id\n status\n }\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endfor %}\n{% endif %}\n", + "script": "{% capture query %}\n query {\n order(id: {{ order.admin_graphql_api_id | json }}) {\n id\n tags\n fulfillmentOrders(\n first: 20\n query: \"status:open\"\n ) {\n nodes {\n id\n assignedLocation {\n name\n }\n deliveryMethod {\n methodType\n }\n }\n }\n }\n }\n{% endcapture %}\n\n{% assign result = query | shopify %}\n\n{% if event.preview %}\n {% capture result_json %}\n {\n \"data\": {\n \"order\": {\n \"id\": \"gid://shopify/Order/1234567890\",\n \"tags\": {{ options.order_tag_to_watch_for__required | json }},\n \"fulfillmentOrders\": {\n \"nodes\": [\n {\n \"id\": \"gid://shopify/FulfillmentOrder/1234567890\",\n \"assignedLocation\": {\n \"name\": \"ACME Warehouse\"\n },\n \"deliveryMethod\": {\n \"methodType\": \"SHIPPING\"\n }\n }\n ]\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = result_json | parse_json %}\n{% endif %}\n\n{% assign order = result.data.order %}\n{% assign fulfillment_orders = order.fulfillmentOrders.nodes %}\n\n{% if fulfillment_orders != blank and order.tags contains options.order_tag_to_watch_for__required %}\n {% if options.ignore_if_this_order_tag_is_found != blank and order.tags contains options.ignore_if_this_order_tag_is_found %}\n {% log message: \"Order had the tag to watch for, but it also had the ignore tag. Skipping.\", order_tags: order.tags, task_options: options %}\n {% break %}\n {% endif %}\n\n {% comment %}\n Note: fulfillments cannot be created with fulfillment orders at different locations or delivery types; so separate them out\n {% endcomment %}\n\n {% assign fulfillment_order_ids_by_location_and_type = hash %}\n\n {% for fulfillment_order in fulfillment_orders %}\n {% assign location_and_type\n = fulfillment_order.assignedLocation.name\n | append: \"|\"\n | append: fulfillment_order.deliveryMethod.methodType\n %}\n {% assign fulfillment_order_ids_by_location_and_type[location_and_type]\n = fulfillment_order_ids_by_location_and_type[location_and_type]\n | default: array\n | push: fulfillment_order.id\n %}\n {% endfor %}\n\n {% for keyval in fulfillment_order_ids_by_location_and_type %}\n {% action \"shopify\" %}\n mutation {\n fulfillmentCreate(\n fulfillment: {\n lineItemsByFulfillmentOrder: [\n {% for fulfillment_order_id in keyval[1] %}\n { fulfillmentOrderId: {{ fulfillment_order_id | json }} }\n {% endfor %}\n ]\n notifyCustomer: {{ options.notify_customer_on_fulfillment__boolean | json }}\n }\n ) {\n fulfillment {\n id\n status\n }\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endfor %}\n{% endif %}\n", "subscriptions": [ "shopify/orders/updated" ], diff --git a/tasks/auto-sort-collections-by-product-properties.json b/tasks/auto-sort-collections-by-product-properties.json index d6173a97..766e70fe 100644 --- a/tasks/auto-sort-collections-by-product-properties.json +++ b/tasks/auto-sort-collections-by-product-properties.json @@ -1,12 +1,12 @@ { - "docs": "This task re-sorts your collections by any product property that you choose. Use the \"Product property lookups\" option to control what attribute the task \"looks up\". For example, using \"published_at\" will result in sorting by the date and time the product was published. Add more than one lookup to dive more deeply into product data: using the lookups \"metafields\", \"store\", \"priority\", and \"value\" will result in a collection sorted by the `store.priority` metafield value on each product.\n\nRefer to [Shopify's API documentation](https://help.shopify.com/en/api/reference/products/product) to find the product property you're looking for.\n\nRun this task manually to re-sort your collections on demand, or choose to run it hourly or nightly.\n\nThis task will scan all collections in the shop on each run, unless you configure it to only sort certain collections using each collection's title or ID. The collections processed by this task must be configured for manual sorting, otherwise they will be ignored. [Learn how to change the sort order of your collections.](https://help.shopify.com/en/manual/products/collections/collection-layout#change-the-sort-order-for-the-products-in-a-collection)", + "docs": "This task re-sorts your collections by the product property, product metafield, or variant property that you choose. Use the \"Product property\" or \"First variant property\" options to control what attribute the task looks up. For example, using `publishedAt` in the \"Product property\" field will result in sorting by the date and time the product was published, while using `sku` in the \"First variant property\" field will result in sorting by the sku of the first variant of each product in the collection. Alternatively, enter a product metafield as \"namespace.key\" (e.g. `store.priority`), and the task will attempt to sort by the value of that metafield.\n\nRun this task manually to re-sort your collections on demand, or choose to run it hourly or nightly. This task will scan all collections in the shop on each run, unless you configure it to only sort certain collections using each collection's title, handle, or ID. Optionally, choose the \"Reverse sort\" option to have the results reversed, mainly useful for sorting by descending numeric values (e.g. `inventoryTotal`).\n\n**Important:**\n\n- You may only choose one sorting method.\n- The collections processed by this task must be configured for manual sorting, otherwise they will be ignored.\n- The property you choose should be a `DateTime`, `Int`, or `String` field on the [GraphQL Product](https://shopify.dev/docs/api/admin-graphql/latest/objects/Product) or [GraphQL ProductVariant](https://shopify.dev/docs/api/admin-graphql/latest/objects/ProductVariant) resource, otherwise the task will generate an error during execution. For help migrating from prior versions of this task, Shopify has documented the mappings for [REST product properties](https://shopify.dev/docs/api/admin-rest/latest/resources/product) and [REST product variant properties](https://shopify.dev/docs/api/admin-rest/latest/resources/product-variant) to GraphQL. (e.g. REST: `published_at` => GraphQL: `publishedAt`)\n- Products with values for a property/metafield will always be placed before products with no values. This rule applies even when the sort order is reversed.\n- If a metafield is configured that does not exist, the task will just report that no moves are needed. No error will be thrown.", "halt_action_run_sequence_on_error": false, - "name": "Auto-sort collections by product properties", + "name": "Auto-sort collections by a product property", "online_store_javascript": null, "options": { - "product_property_lookups__array_required": [ - "published_at" - ], + "product_property": "publishedAt", + "product_metafield": "", + "first_variant_property": "", "only_sort_these_collections__array": null, "reverse_sort__boolean": false, "run_hourly__boolean": false, @@ -14,7 +14,7 @@ }, "order_status_javascript": null, "perform_action_runs_in_sequence": false, - "script": "{% assign product_property_lookups = options.product_property_lookups__array_required %}\n{% assign only_sort_these_collections = options.only_sort_these_collections__array %}\n{% assign reverse_sort = options.reverse_sort__boolean %}\n\n{% if event.topic == \"mechanic/user/trigger\" or event.topic contains \"mechanic/scheduler/\" %}\n {% comment %}\n -- get IDs for all manually sorted collections, optionally restricted to specific collections by ID or title\n {% endcomment %}\n\n {% assign collection_ids_to_sort = array %}\n {% assign cursor = nil %}\n\n {% for n in (1..50) %}\n {% capture query %}\n query {\n collections(\n first: 250\n after: {{ cursor | json }}\n query: {{ search_query | json }}\n ) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n legacyResourceId\n sortOrder\n title\n }\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% if event.preview %}\n {% capture result_json %}\n {\n \"data\": {\n \"collections\": {\n \"nodes\": [\n {\n \"legacyResourceId\": \"1234567890\",\n \"sortOrder\": \"MANUAL\"\n }\n ]\n }\n }\n }\n {% endcapture %}\n\n {% assign result = result_json | parse_json %}\n {% endif %}\n\n {% for collection in result.data.collections.nodes %}\n {% if only_sort_these_collections != blank %}\n {% unless only_sort_these_collections contains collection.legacyResourceId\n or only_sort_these_collections contains collection.title\n or event.preview\n %}\n {% continue %}\n {% endunless %}\n {% endif %}\n\n {% if collection.sortOrder != \"MANUAL\" %}\n {% log\n message: \"Collection is not configured for manual sorting; skipping.\",\n collection: collection\n %}\n {% continue %}\n {% endif %}\n\n {% assign collection_ids_to_sort = collection_ids_to_sort | push: collection.legacyResourceId %}\n {% endfor %}\n\n {% if result.data.collections.pageInfo.hasNextPage %}\n {% assign cursor = result.data.collections.pageInfo.endCursor %}\n {% else %}\n {% break %}\n {% endif %}\n {% endfor %}\n\n {% if collection_ids_to_sort == blank %}\n {% log \"No collections qualified to be sorted on this task run.\" %}\n {% break %}\n {% endif %}\n\n {% comment %}\n -- save the qualified collection IDs to the cache for lookup in later child events\n -- NOTE: using event ID instead of task ID in case one instance of this task runs concurrent with another\n {% endcomment %}\n\n {% assign cache_key = event.id | prepend: \"collection_ids_to_sort_\" %}\n {% action \"cache\", \"set\", cache_key, collection_ids_to_sort %}\n\n {% log\n message: \"Begin processing collections loop using sequential child events.\",\n total_collections_to_sort: collection_ids_to_sort.size\n %}\n\n {% action \"event\" %}\n {\n \"topic\": \"user/collection_sort/process\",\n \"task_id\": {{ task.id | json }},\n \"data\": {\n \"cache_key\": {{ cache_key | json }},\n \"cache_index\": 0\n }\n }\n {% endaction %}\n\n{% elsif event.topic == \"user/collection_sort/process\" %}\n {% assign cache_key = event.data.cache_key %}\n {% assign cache_index = event.data.cache_index %}\n {% assign collection_ids_to_sort = cache[cache_key] %}\n {% assign collection_id = collection_ids_to_sort[cache_index] %}\n {% assign collection = shop.collections[collection_id] %}\n\n {% if event.preview %}\n {% comment %}\n -- use stub data instead of a preview event to incorporate the configured product properties\n {% endcomment %}\n\n {% assign product_ids = array | push: \"1234567890\", \"3456789012\", \"2345678901\" %}\n {% assign products = array %}\n\n {% for product_id in product_ids %}\n {% assign product = hash %}\n\n {% for lookup in product_property_lookups reversed %}\n {% if forloop.first %}\n {% assign product[lookup] = product_id %}\n\n {% else %}\n {% assign temp = product %}\n {% assign product = hash %}\n {% assign product[lookup] = temp %}\n {% endif %}\n {% endfor %}\n\n {% assign product[\"id\"] = product_id %}\n {% assign product[\"admin_graphql_api_id\"] = \"gid://shopify/Product/\" | append: product_id %}\n {% assign products[products.size] = product %}\n {% endfor %}\n\n {% capture collection_json %}\n {\n \"title\": {{ only_sort_these_collections[0] | default: \"Some collection\" | json }},\n \"admin_graphql_api_id\": \"gid://shopify/Collection/1234567890\",\n \"products\": {{ products | json }}\n }\n {% endcapture %}\n\n {% assign collection = collection_json | parse_json %}\n {% endif %}\n\n {% comment %}\n -- process products using REST resources to align with configured product properties\n {% endcomment %}\n\n {% assign moves = array %}\n {% assign product_ids_and_positions = hash %}\n {% assign product_ids_and_values = array %}\n\n {% for product in collection.products %}\n {% assign product_ids_and_positions[product.admin_graphql_api_id] = forloop.index0 %}\n\n {% assign product_id_and_value = hash %}\n {% assign product_id_and_value[\"id\"] = product.admin_graphql_api_id %}\n\n {% assign value = product %}\n\n {% for lookup in options.product_property_lookups__array_required %}\n {% comment %}\n -- NOTE: command methods (size, first, last) can only be invoked via dot notation\n {% endcomment %}\n\n {% case lookup %}\n {% when \"size\" %}\n {% assign value = value.size %}\n {% when \"first\" %}\n {% assign value = value.first %}\n {% when \"last\" %}\n {% assign value = value.last %}\n {% else %}\n {% assign value = value[lookup] %}\n {% endcase %}\n {% endfor %}\n\n {% comment %}\n -- make sure this is always a serializable/sortable object, defaulting to nil in the case of an empty string so that SKU values which are *either* nil or an empty string always end up at the end of the list.\n {% endcomment %}\n\n {% assign product_id_and_value[\"value\"] = value | json | parse_json | default: nil %}\n\n {% assign product_ids_and_values[product_ids_and_values.size] = product_id_and_value %}\n {% endfor %}\n\n {% assign sorted_product_values = product_ids_and_values | sort: \"value\" %}\n {% assign sorted_product_ids = product_ids_and_values | sort: \"value\" | map: \"id\" %}\n\n {% if reverse_sort %}\n {% assign sorted_product_ids = sorted_product_ids | reverse %}\n {% endif %}\n\n {% comment %}\n -- determine the moves necessary to place products in their sorted positions\n {% endcomment %}\n\n {% for sorted_product_id in sorted_product_ids %}\n {% if forloop.index0 != product_ids_and_positions[sorted_product_id] %}\n {% assign move = hash %}\n {% assign move[\"id\"] = sorted_product_id %}\n {% assign move[\"newPosition\"] = \"\" | append: forloop.index0 %}\n {% assign moves[moves.size] = move %}\n {% endif %}\n {% endfor %}\n\n {% if moves == blank %}\n {% log\n message: \"No moves necessary for this collection, everything is already in its appropriate sort order.\",\n collection: collection.title\n %}\n\n {% else %}\n {% log\n message: \"Collection requires sorting.\",\n collection: collection.title\n %}\n\n {% comment %}\n -- using reverse filter below due to a bug in the collectionReorderProducts mutation\n -- this filter will NOT affect the sort order determined above\n {% endcomment %}\n\n {% assign moves_in_groups = moves | reverse | in_groups_of: 250, fill_with: false %}\n\n {% for move_group in moves_in_groups %}\n {% action \"shopify\" %}\n mutation {\n collectionReorderProducts(\n id: {{ collection.admin_graphql_api_id | json }}\n moves: {{ move_group | graphql_arguments }}\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endfor %}\n {% endif %}\n\n {% assign new_cache_index = cache_index | plus: 1 %}\n\n {% log\n collections_seen: new_cache_index,\n total_collections_to_sort: collection_ids_to_sort.size\n %}\n\n {% comment %}\n -- process next collection in the cache if there is one\n {% endcomment %}\n\n {% if new_cache_index < collection_ids_to_sort.size %}\n {% action \"event\" %}\n {\n \"topic\": \"user/collection_sort/process\",\n \"task_id\": {{ task.id | json }},\n \"data\": {\n \"cache_key\": {{ cache_key | json }},\n \"cache_index\": {{ new_cache_index }}\n }\n }\n {% endaction %}\n\n {% else %}\n {% comment %}\n -- use a distinct user event to indicate the entire task run is complete, so it can be filtered in the event log\n {% endcomment %}\n\n {% action \"event\" %}\n {\n \"topic\": \"user/collection_sort/complete\",\n \"task_id\": {{ task.id | json }},\n \"data\": {\n \"cache_key\": {{ cache_key | json }},\n \"cache_index\": {{ new_cache_index }}\n }\n }\n {% endaction %}\n {% endif %}\n\n{% elsif event.topic == \"user/collection_sort/complete\" %}\n {% assign cache_key = event.data.cache_key %}\n {% assign cache_index = event.data.cache_index %}\n\n {% log\n message: \"Collection sorting complete. Deleting cached collection IDs.\",\n collections_seen: cache_index\n %}\n\n {% action \"cache\", \"del\", cache_key %}\n{% endif %}\n", + "script": "{% assign product_property = options.product_property %}\n{% assign product_metafield = options.product_metafield %}\n{% assign first_variant_property = options.first_variant_property %}\n{% assign only_sort_these_collections = options.only_sort_these_collections__array %}\n{% assign reverse_sort = options.reverse_sort__boolean %}\n\n{% comment %}\n -- build the query fragment for the configured property\n{% endcomment %}\n\n{% assign property_fields_count = 0 %}\n\n{% if product_property != blank %}\n {% assign property_fields_count = property_fields_count | plus: 1 %}\n{% endif %}\n\n{% if product_metafield != blank %}\n {% assign property_fields_count = property_fields_count | plus: 1 %}\n{% endif %}\n\n{% if first_variant_property != blank %}\n {% assign property_fields_count = property_fields_count | plus: 1 %}\n{% endif %}\n\n{% if property_fields_count != 1 %}\n {% error \"Configure one (and only one) of these options: 'Product property', 'Product metafield', or 'First variant property'\" %}\n {% break %}\n{% endif %}\n\n{% assign query_fragment = nil %}\n\n{% if product_property != blank %}\n {% assign query_fragment = product_property %}\n\n{% elsif product_metafield != blank %}\n {%- capture query_fragment -%}\n metafield(key: {{ product_metafield | json }}) {\n value\n }\n {%- endcapture -%}\n\n{% elsif first_variant_property != blank %}\n {%- capture query_fragment -%}\n variants(first: 1) {\n nodes {\n {{ first_variant_property }}\n }\n }\n {%- endcapture -%}\n{% endif %}\n\n{% if event.topic == \"mechanic/user/trigger\" or event.topic contains \"mechanic/scheduler/\" %}\n {% comment %}\n -- get IDs for all manually sorted collections, optionally restricted to specific collections by ID or title\n {% endcomment %}\n\n {% assign collection_ids_to_sort = array %}\n {% assign cursor = nil %}\n\n {% for n in (1..50) %}\n {% capture query %}\n query {\n collections(\n first: 250\n after: {{ cursor | json }}\n ) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n id\n legacyResourceId\n title\n handle\n sortOrder\n }\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% if event.preview %}\n {% capture result_json %}\n {\n \"data\": {\n \"collections\": {\n \"nodes\": [\n {\n \"id\": \"gid://shopify/Collection/1234567890\",\n \"legacyResourceId\": \"1234567890\",\n \"sortOrder\": \"MANUAL\"\n }\n ]\n }\n }\n }\n {% endcapture %}\n\n {% assign result = result_json | parse_json %}\n {% endif %}\n\n {% comment %}\n -- loop through collections and filter by ID, title, or handle as configured\n {% endcomment %}\n\n {% for collection in result.data.collections.nodes %}\n {% if only_sort_these_collections != blank %}\n {% unless only_sort_these_collections contains collection.legacyResourceId\n or only_sort_these_collections contains collection.title\n or only_sort_these_collections contains collection.handle\n or event.preview\n %}\n {% continue %}\n {% endunless %}\n {% endif %}\n\n {% if collection.sortOrder != \"MANUAL\" %}\n {% log %}\n {{ collection.title | json | append: \" is not configured for manual sorting; skipping.\" | json }}\n {% endlog %}\n {% continue %}\n {% endif %}\n\n {% assign collection_ids_to_sort = collection_ids_to_sort | push: collection.id %}\n {% endfor %}\n\n {% if result.data.collections.pageInfo.hasNextPage %}\n {% assign cursor = result.data.collections.pageInfo.endCursor %}\n {% else %}\n {% break %}\n {% endif %}\n {% endfor %}\n\n {% if collection_ids_to_sort == blank %}\n {% log \"None of the configured collections were found or set for manual sorting.\" %}\n {% break %}\n {% endif %}\n\n {% comment %}\n -- save the qualified collection IDs to the cache for lookup in later child events\n -- NOTE: using event ID instead of task ID in case one instance of this task runs concurrent with another\n {% endcomment %}\n\n {% assign cache_key = event.id | prepend: \"collection_ids_to_sort_\" %}\n {% action \"cache\", \"set\", cache_key, collection_ids_to_sort %}\n\n {% log\n message: \"Begin processing collections loop using sequential child events.\",\n total_collections_to_sort: collection_ids_to_sort.size\n %}\n\n {% action \"event\" %}\n {\n \"topic\": \"user/collection_sort/process\",\n \"task_id\": {{ task.id | json }},\n \"data\": {\n \"cache_key\": {{ cache_key | json }},\n \"cache_index\": 0\n }\n }\n {% endaction %}\n\n{% elsif event.topic == \"user/collection_sort/process\" %}\n {% assign cache_key = event.data.cache_key %}\n {% assign cache_index = event.data.cache_index %}\n {% assign collection_ids_to_sort = cache[cache_key] %}\n {% assign collection_id = collection_ids_to_sort[cache_index] %}\n\n {% assign moves = array %}\n {% assign product_ids_and_positions = hash %}\n {% assign product_ids_and_values = array %}\n\n {% assign cursor = nil %}\n {% assign products = array %}\n\n {% for n in (1..100) %}\n {% capture query %}\n query {\n collection(id: {{ collection_id | json }}) {\n id\n title\n products(\n first: 250\n after: {{ cursor | json }}\n sortKey: COLLECTION_DEFAULT\n ) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n id\n {{ query_fragment }}\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% if event.preview %}\n {% capture result_json %}\n {\n \"data\": {\n \"collection\": {\n \"id\": \"gid://shopify/Collection/1234567890\",\n \"title\": \"Widgets\",\n \"products\": {\n \"nodes\": [\n {\n \"id\": \"gid://shopify/Product/1234567890\"\n },\n {\n \"id\": \"gid://shopify/Product/2345678901\"\n }\n ]\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = result_json | parse_json %}\n {% endif %}\n\n {% assign collection = result.data.collection %}\n {% assign products = products | concat: result.data.collection.products.nodes %}\n\n {% if result.data.products.pageInfo.hasNextPage %}\n {% assign cursor = result.data.products.pageInfo.endCursor %}\n {% else %}\n {% break %}\n {% endif %}\n {% endfor %}\n\n {% log collection_products: products %}\n\n {% for product in products %}\n {% assign product_ids_and_positions[product.id] = forloop.index0 %}\n\n {% assign product_id_and_value = hash %}\n {% assign product_id_and_value[\"id\"] = product.id %}\n\n {% if product_property != blank %}\n {% assign value = product[product_property] %}\n\n {% elsif product_metafield != blank %}\n {% assign value = product.metafield.value %}\n\n {% elsif first_variant_property != blank %}\n {% assign value = product.variants.nodes.first[first_variant_property] %}\n {% endif %}\n\n {% comment %}\n -- make sure this is always a serializable/sortable object, defaulting to nil in the case of an empty string\n -- values which are *either* nil or an empty string will always end up at the end of the list\n {% endcomment %}\n\n {% assign product_id_and_value[\"value\"] = value | json | parse_json | default: nil %}\n {% assign product_ids_and_values[product_ids_and_values.size] = product_id_and_value %}\n {% endfor %}\n\n {% assign sorted_product_values = product_ids_and_values | sort: \"value\" %}\n {% assign sorted_product_ids = sorted_product_values | map: \"id\" %}\n\n {% if reverse_sort %}\n {% comment %}\n -- only reverse the order of products which have values, leaving nil values at the end\n {% endcomment %}\n\n {% assign sorted_product_ids\n = sorted_product_values\n | where: \"value\"\n | map: \"id\"\n | reverse\n | concat: sorted_product_ids\n | uniq\n %}\n {% endif %}\n\n {% log sorted_product_ids: sorted_product_ids %}\n\n {% comment %}\n -- determine the moves necessary to place products in their sorted positions\n {% endcomment %}\n\n {% for sorted_product_id in sorted_product_ids %}\n {% if forloop.index0 != product_ids_and_positions[sorted_product_id] %}\n {% assign move = hash %}\n {% assign move[\"id\"] = sorted_product_id %}\n {% assign move[\"newPosition\"] = \"\" | append: forloop.index0 %}\n {% assign moves[moves.size] = move %}\n {% endif %}\n {% endfor %}\n\n {% comment %}\n -- add preview here so collectionReorderProducts is reached, and the proper scopes are requested\n {% endcomment %}\n\n {% if event.preview %}\n {% capture moves_json %}\n [\n {\n \"id\": \"gid://shopify/Product/1234567890\",\n \"newPosition\": \"1\"\n },\n {\n \"id\": \"gid://shopify/Product/2345678901\",\n \"newPosition\": \"0\"\n }\n ]\n {% endcapture %}\n\n {% assign moves = moves_json | parse_json %}\n {% endif %}\n\n {% if moves == blank %}\n {% log\n message: \"No moves necessary for this collection, everything is already in its appropriate sort order.\",\n collection: collection.title\n %}\n\n {% else %}\n {% log\n message: \"Collection requires sorting.\",\n collection: collection.title\n %}\n\n {% comment %}\n -- using reverse filter below due to a bug in the collectionReorderProducts mutation\n -- this filter will NOT affect the sort order determined above\n {% endcomment %}\n\n {% assign moves_in_groups = moves | reverse | in_groups_of: 250, fill_with: false %}\n\n {% for move_group in moves_in_groups %}\n {% action \"shopify\" %}\n mutation {\n collectionReorderProducts(\n id: {{ collection.id | json }}\n moves: {{ move_group | graphql_arguments }}\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endfor %}\n {% endif %}\n\n {% assign new_cache_index = cache_index | plus: 1 %}\n\n {% log\n collections_seen: new_cache_index,\n total_collections_to_sort: collection_ids_to_sort.size\n %}\n\n {% comment %}\n -- process next collection in the cache if there is one\n {% endcomment %}\n\n {% if new_cache_index < collection_ids_to_sort.size %}\n {% action \"event\" %}\n {\n \"topic\": \"user/collection_sort/process\",\n \"task_id\": {{ task.id | json }},\n \"data\": {\n \"cache_key\": {{ cache_key | json }},\n \"cache_index\": {{ new_cache_index }}\n }\n }\n {% endaction %}\n\n {% else %}\n {% comment %}\n -- use a distinct user event to indicate the entire task run is complete, so it can be filtered in the event log\n {% endcomment %}\n\n {% action \"event\" %}\n {\n \"topic\": \"user/collection_sort/complete\",\n \"task_id\": {{ task.id | json }},\n \"data\": {\n \"cache_key\": {{ cache_key | json }},\n \"cache_index\": {{ new_cache_index }}\n }\n }\n {% endaction %}\n {% endif %}\n\n{% elsif event.topic == \"user/collection_sort/complete\" %}\n {% assign cache_key = event.data.cache_key %}\n {% assign cache_index = event.data.cache_index %}\n\n {% log\n message: \"Collection sorting complete. Deleting cached collection IDs.\",\n collections_seen: cache_index\n %}\n\n {% action \"cache\", \"del\", cache_key %}\n{% endif %}\n", "subscriptions": [ "mechanic/user/trigger", "user/collection_sort/process", diff --git a/tasks/auto-tag-products-that-are-missing-costs.json b/tasks/auto-tag-products-that-are-missing-costs.json index 20407643..cb50d737 100644 --- a/tasks/auto-tag-products-that-are-missing-costs.json +++ b/tasks/auto-tag-products-that-are-missing-costs.json @@ -8,13 +8,14 @@ }, "order_status_javascript": null, "perform_action_runs_in_sequence": false, - "script": "{% if event.preview %}\n {% action \"shopify\" %}\n mutation {\n tagsAdd(\n id: \"gid://shopify/Product/1234567890\"\n tags: {{ options.tag_for_cost_missing__required | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n{% elsif event.topic == \"mechanic/user/trigger\" %}\n {% for product in shop.products %}\n {% assign missing_cost = true %}\n {% for variant in product.variants %}\n {% if variant.inventory_item.cost != blank %}\n {% assign missing_cost = false %}\n {% break %}\n {% endif %}\n {% endfor %}\n\n {% assign product_tags = product.tags | split: \", \" %}\n\n {% if missing_cost %}\n {% unless product_tags contains options.tag_for_cost_missing__required %}\n {% action \"shopify\" %}\n mutation {\n tagsAdd(\n id: {{ product.admin_graphql_api_id | json }}\n tags: {{ options.tag_for_cost_missing__required | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endunless %}\n {% elsif product_tags contains options.tag_for_cost_missing__required %}\n {% action \"shopify\" %}\n mutation {\n tagsRemove(\n id: {{ product.admin_graphql_api_id | json }}\n tags: {{ options.tag_for_cost_missing__required | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endif %}\n {% endfor %}\n{% elsif event.topic == \"shopify/products/create\" or event.topic == \"shopify/products/update\" %}\n {% assign missing_cost = true %}\n {% for variant in product.variants %}\n {% if variant.inventory_item.cost != blank %}\n {% assign missing_cost = false %}\n {% break %}\n {% endif %}\n {% endfor %}\n\n {% assign product_tags = product.tags | split: \", \" %}\n\n {% if missing_cost %}\n {% unless product_tags contains options.tag_for_cost_missing__required %}\n {% action \"shopify\" %}\n mutation {\n tagsAdd(\n id: {{ product.admin_graphql_api_id | json }}\n tags: {{ options.tag_for_cost_missing__required | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endunless %}\n {% elsif product_tags contains options.tag_for_cost_missing__required %}\n {% action \"shopify\" %}\n mutation {\n tagsRemove(\n id: {{ product.admin_graphql_api_id | json }}\n tags: {{ options.tag_for_cost_missing__required | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endif %}\n{% endif %}", + "script": "{% assign tag_for_cost_missing = options.tag_for_cost_missing__required %}\n\n{% if event.topic == \"shopify/products/create\" or event.topic == \"shopify/products/update\" %}\n {% assign missing_cost = nil %}\n {% assign cursor = nil %}\n\n {% comment %}\n -- query costs of variant inventory items, breaking if one is found without a cost; support up to 2k variants\n {% endcomment %}\n\n {% for n in (1..8) %}\n {% capture query %}\n query {\n product(id: {{ product.admin_graphql_api_id | json }}) {\n id\n tags\n variants(\n first: 250\n after: {{ cursor | json }}\n ) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n inventoryItem {\n unitCost {\n amount\n }\n }\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% if event.preview %}\n {% capture result_json %}\n {\n \"data\": {\n \"product\": {\n \"id\": \"gid://shopify/Product/1234567890\",\n \"variants\": {\n \"nodes\": [\n {\n \"inventoryItem\": {\n \"unitCost\": null\n }\n }\n ]\n }\n\n }\n }\n }\n {% endcapture %}\n\n {% assign result = result_json | parse_json %}\n {% endif %}\n\n {% assign product = result.data.product %}\n\n {% for variant in product.variants.nodes %}\n {% if variant.inventoryItem.unitCost == blank %}\n {% assign missing_cost = true %}\n {% break %}\n {% endif %}\n {% endfor %}\n\n {% if missing_cost %}\n {% break %}\n {% endif %}\n\n {% if result.data.product.variants.pageInfo.hasNextPage %}\n {% assign cursor = result.data.product.variants.pageInfo.endCursor %}\n {% else %}\n {% break %}\n {% endif %}\n {% endfor %}\n\n {% if missing_cost %}\n {% unless product.tags contains tag_for_cost_missing %}\n {% action \"shopify\" %}\n mutation {\n tagsAdd(\n id: {{ product.id | json }}\n tags: {{ tag_for_cost_missing | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endunless %}\n\n {% elsif product.tags contains tag_for_cost_missing %}\n {% action \"shopify\" %}\n mutation {\n tagsRemove(\n id: {{ product.id | json }}\n tags: {{ tag_for_cost_missing | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endif %}\n\n{% elsif event.topic == \"mechanic/user/trigger\" %}\n {% comment %}\n -- query all products in the shop and their variant inventory items\n {% endcomment %}\n\n {% capture bulk_operation_query %}\n query {\n products {\n edges {\n node {\n __typename\n id\n tags\n variants {\n edges {\n node {\n __typename\n id\n inventoryItem {\n unitCost {\n amount\n }\n }\n }\n }\n }\n }\n }\n }\n }\n {% endcapture %}\n\n {% action \"shopify\" %}\n mutation {\n bulkOperationRunQuery(\n query: {{ bulk_operation_query | json }}\n ) {\n bulkOperation {\n id\n status\n }\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n\n{% elsif event.topic == \"mechanic/shopify/bulk_operation\" %}\n {% if event.preview %}\n {% capture jsonl_string %}\n {\"__typename\":\"Product\",\"id\":\"gid://shopify/Product/1234567890\"}\n {\"__typename\":\"ProductVariant\",\"id\":\"gid://shopify/ProductVariant/1234567890\",\"inventoryItem\":{\"unitCost\":null},\"__parentId\":\"gid://shopify/Product/1234567890\"}\n {% endcapture %}\n\n {% assign bulkOperation = hash %}\n {% assign bulkOperation[\"objects\"] = jsonl_string | parse_jsonl %}\n {% endif %}\n\n {% assign products = bulkOperation.objects | where: \"__typename\", \"Product\" %}\n {% assign bulk_variants = bulkOperation.objects | where: \"__typename\", \"ProductVariant\" %}\n\n {% comment %}\n -- process all products returned by the bulk op query\n {% endcomment %}\n\n {% for product in products %}\n {% comment %}\n -- link the variants to the product and check if any have a missing unitCost\n {% endcomment %}\n\n {% assign variants = bulk_variants | where: \"__parentId\", product.id %}\n\n {% assign missing_cost = nil %}\n\n {% for variant in variants %}\n {% if variant.inventoryItem.unitCost == blank %}\n {% assign missing_cost = true %}\n {% break %}\n {% endif %}\n {% endfor %}\n\n {% if missing_cost %}\n {% unless product.tags contains tag_for_cost_missing %}\n {% action \"shopify\" %}\n mutation {\n tagsAdd(\n id: {{ product.id | json }}\n tags: {{ tag_for_cost_missing | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endunless %}\n\n {% elsif product.tags contains tag_for_cost_missing %}\n {% action \"shopify\" %}\n mutation {\n tagsRemove(\n id: {{ product.id | json }}\n tags: {{ tag_for_cost_missing | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endif %}\n {% endfor %}\n{% endif %}\n", "subscriptions": [ - "mechanic/user/trigger", "shopify/products/create", - "shopify/products/update" + "shopify/products/update", + "mechanic/user/trigger", + "mechanic/shopify/bulk_operation" ], - "subscriptions_template": "mechanic/user/trigger\nshopify/products/create\nshopify/products/update", + "subscriptions_template": "shopify/products/create\nshopify/products/update\nmechanic/user/trigger\nmechanic/shopify/bulk_operation", "tags": [ "Auto-Tag", "Costs", diff --git a/tasks/send-email-when-an-order-comes-in.json b/tasks/send-email-when-an-order-comes-in.json index 15a00870..27b3f6df 100644 --- a/tasks/send-email-when-an-order-comes-in.json +++ b/tasks/send-email-when-an-order-comes-in.json @@ -12,7 +12,7 @@ }, "order_status_javascript": null, "perform_action_runs_in_sequence": false, - "script": "{% assign order_qualifies = false %}\n{% if options.only_for_orders_including_this_product_tag == blank %}\n {% assign order_qualifies = true %}\n{% else %}\n {% assign order_product_tags = order.line_items | map: \"product\" | map: \"tags\" | join: \", \" | split: \", \" | sort | uniq %}\n {% if order_product_tags contains options.only_for_orders_including_this_product_tag %}\n {% assign order_qualifies = true %}\n {% endif %}\n{% endif %}\n\n{% if event.preview or order_qualifies %}\n {\n \"action\": {\n \"type\": \"email\",\n \"options\": {\n \"to\": {{ options.email_recipients__required | json }},\n \"subject\": {{ options.email_subject__required | strip | json }},\n \"body\": {{ options.email_body__required_multiline | newline_to_br | strip | json }},\n \"reply_to\": {{ shop.customer_email | json }},\n \"from_display_name\": {{ shop.name | json }}\n }\n }\n }\n{% endif %}", + "script": "{% assign only_for_orders_including_this_product_tag = options.only_for_orders_including_this_product_tag %}\n{% assign email_recipients = options.email_recipients__required %}\n{% assign email_subject = options.email_subject__required %}\n{% assign email_body = options.email_body__required_multiline %}\n\n{% if only_for_orders_including_this_product_tag == blank %}\n {% assign order_qualifies = true %}\n\n{% else %}\n {% capture query %}\n query {\n order(id: {{ order.admin_graphql_api_id | json }}) {\n lineItems(first: 250) {\n nodes {\n product {\n tags\n }\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% if event.preview %}\n {% capture result_json %}\n {\n \"data\": {\n \"order\": {\n \"lineItems\": {\n \"nodes\": [\n {\n \"product\": {\n \"tags\": {{ only_for_orders_including_this_product_tag | json }}\n }\n }\n ]\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = result_json | parse_json %}\n {% endif %}\n\n {% for line_item in result.data.order.lineItems.nodes %}\n {% if line_item.product.tags contains only_for_orders_including_this_product_tag %}\n {% assign order_qualifies = true %}\n {% break %}\n {% endif %}\n {% endfor %}\n{% endif %}\n\n{% if order_qualifies %}\n {% action \"email\" %}\n {\n \"to\": {{ email_recipients | json }},\n \"subject\": {{ email_subject | json }},\n \"body\": {{ email_body | newline_to_br | json }},\n \"reply_to\": {{ shop.customer_email | json }},\n \"from_display_name\": {{ shop.name | json }}\n }\n {% endaction %}\n{% endif %}\n", "subscriptions": [ "shopify/orders/create" ], diff --git a/tasks/set-product-templates-based-on-product-tags.json b/tasks/set-product-templates-based-on-product-tags.json index c99e5038..84bd5376 100644 --- a/tasks/set-product-templates-based-on-product-tags.json +++ b/tasks/set-product-templates-based-on-product-tags.json @@ -9,7 +9,7 @@ "order_status_javascript": null, "perform_action_runs_in_sequence": false, "preview_event_definitions": [], - "script": "{% assign product_tags_and_template_suffixes = options.product_tags_and_template_suffixes__keyval_required %}\n\n{% assign products = array %}\n\n{% if event.topic == \"mechanic/user/trigger\" %}\n {% capture bulk_operation_query %}\n query {\n products {\n edges {\n node {\n __typename\n id\n tags\n templateSuffix\n }\n }\n }\n }\n {% endcapture %}\n\n {% action \"shopify\" %}\n mutation {\n bulkOperationRunQuery(\n query: {{ bulk_operation_query | json }}\n ) {\n bulkOperation {\n id\n status\n }\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n\n{% elsif event.topic contains \"shopify/products/\" or event.preview %}\n {% capture query %}\n query {\n product(id: {{ product.admin_graphql_api_id | json }} ) {\n id\n tags\n templateSuffix\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% assign products[0] = result.data.product %}\n\n{% elsif event.topic == \"mechanic/shopify/bulk_operation\" %}\n {% assign products = bulkOperation.objects %}\n{% endif %}\n\n{% if event.preview %}\n {% capture products_json %}\n [\n {\n \"id\": \"gid://shopify/Product/4354268561469\",\n \"tags\": [\"123\", {{ product_tags_and_template_suffixes.first.first | json }}],\n \"templateSuffix\": \"preview-1234567890\"\n }\n ]\n {% endcapture %}\n\n {% assign products = products_json | parse_json %}\n{% endif %}\n\n{% for product in products %}\n {% assign product_template_suffix_to_apply = nil %}\n\n {% for keyval in product_tags_and_template_suffixes %}\n {% assign product_tag_to_check = keyval[0] %}\n {% assign product_template_to_apply = keyval[1] %}\n\n {% if product.tags contains product_tag_to_check %}\n {% assign product_template_suffix_to_apply = product_template_to_apply %}\n {% break %}\n {% endif %}\n {% endfor %}\n\n {% if product_template_suffix_to_apply != product.templateSuffix %}\n {% action \"shopify\" %}\n mutation {\n productUpdate(\n input: {\n id: {{ product.id | json }}\n templateSuffix: {{ product_template_suffix_to_apply | json }}\n }\n ) {\n product {\n id\n templateSuffix\n }\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endif %}\n{% endfor %}\n", + "script": "{% assign product_tags_and_template_suffixes = options.product_tags_and_template_suffixes__keyval_required %}\n\n{% assign products = array %}\n\n{% if event.topic == \"mechanic/user/trigger\" %}\n {% capture bulk_operation_query %}\n query {\n products {\n edges {\n node {\n __typename\n id\n tags\n templateSuffix\n }\n }\n }\n }\n {% endcapture %}\n\n {% action \"shopify\" %}\n mutation {\n bulkOperationRunQuery(\n query: {{ bulk_operation_query | json }}\n ) {\n bulkOperation {\n id\n status\n }\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n\n{% elsif event.topic contains \"shopify/products/\" or event.preview %}\n {% capture query %}\n query {\n product(id: {{ product.admin_graphql_api_id | json }} ) {\n id\n tags\n templateSuffix\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% assign products[0] = result.data.product %}\n\n{% elsif event.topic == \"mechanic/shopify/bulk_operation\" %}\n {% assign products = bulkOperation.objects %}\n{% endif %}\n\n{% if event.preview %}\n {% capture products_json %}\n [\n {\n \"id\": \"gid://shopify/Product/4354268561469\",\n \"tags\": [\"123\", {{ product_tags_and_template_suffixes.first.first | json }}],\n \"templateSuffix\": \"preview-1234567890\"\n }\n ]\n {% endcapture %}\n\n {% assign products = products_json | parse_json %}\n{% endif %}\n\n{% for product in products %}\n {% assign product_template_suffix_to_apply = nil %}\n\n {% for keyval in product_tags_and_template_suffixes %}\n {% assign product_tag_to_check = keyval[0] %}\n {% assign product_template_to_apply = keyval[1] %}\n\n {% if product.tags contains product_tag_to_check %}\n {% assign product_template_suffix_to_apply = product_template_to_apply %}\n {% break %}\n {% endif %}\n {% endfor %}\n\n {% if product_template_suffix_to_apply != product.templateSuffix %}\n {% action \"shopify\" %}\n mutation {\n productUpdate(\n product: {\n id: {{ product.id | json }}\n templateSuffix: {{ product_template_suffix_to_apply | json }}\n }\n ) {\n product {\n id\n templateSuffix\n }\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endif %}\n{% endfor %}\n", "subscriptions": [ "shopify/products/create", "shopify/products/update",