feat: add shopify-development skill
- Add comprehensive Shopify development skill with validated GraphQL - Fixed 4 mutations using Shopify MCP (fulfillmentCreate, appSubscription, etc.) - Added shopify_graphql.py utilities with pagination & rate limiting - Updated API version to 2026-01 - Added zircote/.claude as reference source
This commit is contained in:
@@ -244,6 +244,7 @@ This collection would not be possible without the incredible work of the Claude
|
||||
- **[zebbern/claude-code-guide](https://github.com/zebbern/claude-code-guide)**: Comprehensive Security suite & Guide (Source for ~60 new skills).
|
||||
- **[alirezarezvani/claude-skills](https://github.com/alirezarezvani/claude-skills)**: Senior Engineering and PM toolkit.
|
||||
- **[karanb192/awesome-claude-skills](https://github.com/karanb192/awesome-claude-skills)**: A massive list of verified skills for Claude Code.
|
||||
- **[zircote/.claude](https://github.com/zircote/.claude)**: Shopify development skill reference.
|
||||
|
||||
### Inspirations
|
||||
|
||||
@@ -270,6 +271,6 @@ For repository maintainers, add these topics to maximize discoverability:
|
||||
claude-code, gemini-cli, codex-cli, antigravity, cursor, github-copilot, opencode,
|
||||
agentic-skills, ai-coding, llm-tools, ai-agents, autonomous-coding, mcp,
|
||||
ai-developer-tools, ai-pair-programming, vibe-coding, skill, skills, SKILL.md, rules.md, CLAUDE.md, GEMINI.md, CURSOR.md
|
||||
claude-code, gemini-cli, codex-cli, antigravity, cursor, github-copilot, opencode,
|
||||
claude-code, gemini-cli, codex-cli, antigravity, cursor, github-copilot, opencode,
|
||||
agentic-skills, ai-coding, llm-tools, ai-agents, autonomous-coding, mcp
|
||||
```
|
||||
|
||||
60
skills/shopify-development/README.md
Normal file
60
skills/shopify-development/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# Shopify Development Skill
|
||||
|
||||
Comprehensive skill for building on Shopify platform: apps, extensions, themes, and API integrations.
|
||||
|
||||
## Features
|
||||
|
||||
- **App Development** - OAuth authentication, GraphQL Admin API, webhooks, billing integration
|
||||
- **UI Extensions** - Checkout, Admin, POS customizations with Polaris components
|
||||
- **Theme Development** - Liquid templating, sections, snippets
|
||||
- **Shopify Functions** - Custom discounts, payment, delivery rules
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
shopify-development/
|
||||
├── SKILL.md # Main skill file (AI-optimized)
|
||||
├── README.md # This file
|
||||
├── references/
|
||||
│ ├── app-development.md # OAuth, API, webhooks, billing
|
||||
│ ├── extensions.md # UI extensions, Functions
|
||||
│ └── themes.md # Liquid, theme architecture
|
||||
└── scripts/
|
||||
├── shopify_init.py # Interactive project scaffolding
|
||||
├── shopify_graphql.py # GraphQL utilities & templates
|
||||
└── tests/ # Unit tests
|
||||
```
|
||||
|
||||
## Validated GraphQL
|
||||
|
||||
All GraphQL queries and mutations in this skill have been validated against Shopify Admin API 2026-01 schema using the official Shopify MCP.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Install Shopify CLI
|
||||
npm install -g @shopify/cli@latest
|
||||
|
||||
# Create new app
|
||||
shopify app init
|
||||
|
||||
# Start development
|
||||
shopify app dev
|
||||
```
|
||||
|
||||
## Usage Triggers
|
||||
|
||||
This skill activates when the user mentions:
|
||||
|
||||
- "shopify app", "shopify extension", "shopify theme"
|
||||
- "checkout extension", "admin extension", "POS extension"
|
||||
- "liquid template", "polaris", "shopify graphql"
|
||||
- "shopify webhook", "shopify billing", "metafields"
|
||||
|
||||
## API Version
|
||||
|
||||
Current: **2026-01** (Quarterly releases with 12-month support)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
366
skills/shopify-development/SKILL.md
Normal file
366
skills/shopify-development/SKILL.md
Normal file
@@ -0,0 +1,366 @@
|
||||
---
|
||||
name: shopify-development
|
||||
description: |
|
||||
Build Shopify apps, extensions, themes using GraphQL Admin API, Shopify CLI, Polaris UI, and Liquid.
|
||||
TRIGGER: "shopify", "shopify app", "checkout extension", "admin extension", "POS extension",
|
||||
"shopify theme", "liquid template", "polaris", "shopify graphql", "shopify webhook",
|
||||
"shopify billing", "app subscription", "metafields", "shopify functions"
|
||||
---
|
||||
|
||||
# Shopify Development Skill
|
||||
|
||||
Use this skill when the user asks about:
|
||||
|
||||
- Building Shopify apps or extensions
|
||||
- Creating checkout/admin/POS UI customizations
|
||||
- Developing themes with Liquid templating
|
||||
- Integrating with Shopify GraphQL or REST APIs
|
||||
- Implementing webhooks or billing
|
||||
- Working with metafields or Shopify Functions
|
||||
|
||||
---
|
||||
|
||||
## ROUTING: What to Build
|
||||
|
||||
**IF user wants to integrate external services OR build merchant tools OR charge for features:**
|
||||
→ Build an **App** (see `references/app-development.md`)
|
||||
|
||||
**IF user wants to customize checkout OR add admin UI OR create POS actions OR implement discount rules:**
|
||||
→ Build an **Extension** (see `references/extensions.md`)
|
||||
|
||||
**IF user wants to customize storefront design OR modify product/collection pages:**
|
||||
→ Build a **Theme** (see `references/themes.md`)
|
||||
|
||||
**IF user needs both backend logic AND storefront UI:**
|
||||
→ Build **App + Theme Extension** combination
|
||||
|
||||
---
|
||||
|
||||
## Shopify CLI Commands
|
||||
|
||||
Install CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @shopify/cli@latest
|
||||
```
|
||||
|
||||
Create and run app:
|
||||
|
||||
```bash
|
||||
shopify app init # Create new app
|
||||
shopify app dev # Start dev server with tunnel
|
||||
shopify app deploy # Build and upload to Shopify
|
||||
```
|
||||
|
||||
Generate extension:
|
||||
|
||||
```bash
|
||||
shopify app generate extension --type checkout_ui_extension
|
||||
shopify app generate extension --type admin_action
|
||||
shopify app generate extension --type admin_block
|
||||
shopify app generate extension --type pos_ui_extension
|
||||
shopify app generate extension --type function
|
||||
```
|
||||
|
||||
Theme development:
|
||||
|
||||
```bash
|
||||
shopify theme init # Create new theme
|
||||
shopify theme dev # Start local preview at localhost:9292
|
||||
shopify theme pull --live # Pull live theme
|
||||
shopify theme push --development # Push to dev theme
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Access Scopes
|
||||
|
||||
Configure in `shopify.app.toml`:
|
||||
|
||||
```toml
|
||||
[access_scopes]
|
||||
scopes = "read_products,write_products,read_orders,write_orders,read_customers"
|
||||
```
|
||||
|
||||
Common scopes:
|
||||
|
||||
- `read_products`, `write_products` - Product catalog access
|
||||
- `read_orders`, `write_orders` - Order management
|
||||
- `read_customers`, `write_customers` - Customer data
|
||||
- `read_inventory`, `write_inventory` - Stock levels
|
||||
- `read_fulfillments`, `write_fulfillments` - Order fulfillment
|
||||
|
||||
---
|
||||
|
||||
## GraphQL Patterns (Validated against API 2026-01)
|
||||
|
||||
### Query Products
|
||||
|
||||
```graphql
|
||||
query GetProducts($first: Int!, $query: String) {
|
||||
products(first: $first, query: $query) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
handle
|
||||
status
|
||||
variants(first: 5) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
price
|
||||
inventoryQuantity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Query Orders
|
||||
|
||||
```graphql
|
||||
query GetOrders($first: Int!) {
|
||||
orders(first: $first) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
createdAt
|
||||
displayFinancialStatus
|
||||
totalPriceSet {
|
||||
shopMoney {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Set Metafields
|
||||
|
||||
```graphql
|
||||
mutation SetMetafields($metafields: [MetafieldsSetInput!]!) {
|
||||
metafieldsSet(metafields: $metafields) {
|
||||
metafields {
|
||||
id
|
||||
namespace
|
||||
key
|
||||
value
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Variables example:
|
||||
|
||||
```json
|
||||
{
|
||||
"metafields": [
|
||||
{
|
||||
"ownerId": "gid://shopify/Product/123",
|
||||
"namespace": "custom",
|
||||
"key": "care_instructions",
|
||||
"value": "Handle with care",
|
||||
"type": "single_line_text_field"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checkout Extension Example
|
||||
|
||||
```tsx
|
||||
import {
|
||||
reactExtension,
|
||||
BlockStack,
|
||||
TextField,
|
||||
Checkbox,
|
||||
useApplyAttributeChange,
|
||||
} from "@shopify/ui-extensions-react/checkout";
|
||||
|
||||
export default reactExtension("purchase.checkout.block.render", () => (
|
||||
<GiftMessage />
|
||||
));
|
||||
|
||||
function GiftMessage() {
|
||||
const [isGift, setIsGift] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const applyAttributeChange = useApplyAttributeChange();
|
||||
|
||||
useEffect(() => {
|
||||
if (isGift && message) {
|
||||
applyAttributeChange({
|
||||
type: "updateAttribute",
|
||||
key: "gift_message",
|
||||
value: message,
|
||||
});
|
||||
}
|
||||
}, [isGift, message]);
|
||||
|
||||
return (
|
||||
<BlockStack spacing="loose">
|
||||
<Checkbox checked={isGift} onChange={setIsGift}>
|
||||
This is a gift
|
||||
</Checkbox>
|
||||
{isGift && (
|
||||
<TextField
|
||||
label="Gift Message"
|
||||
value={message}
|
||||
onChange={setMessage}
|
||||
multiline={3}
|
||||
/>
|
||||
)}
|
||||
</BlockStack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Liquid Template Example
|
||||
|
||||
```liquid
|
||||
{% comment %} Product Card Snippet {% endcomment %}
|
||||
<div class="product-card">
|
||||
<a href="{{ product.url }}">
|
||||
{% if product.featured_image %}
|
||||
<img
|
||||
src="{{ product.featured_image | img_url: 'medium' }}"
|
||||
alt="{{ product.title | escape }}"
|
||||
loading="lazy"
|
||||
>
|
||||
{% endif %}
|
||||
<h3>{{ product.title }}</h3>
|
||||
<p class="price">{{ product.price | money }}</p>
|
||||
{% if product.compare_at_price > product.price %}
|
||||
<p class="sale-badge">Sale</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhook Configuration
|
||||
|
||||
In `shopify.app.toml`:
|
||||
|
||||
```toml
|
||||
[webhooks]
|
||||
api_version = "2026-01"
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
topics = ["orders/create", "orders/updated"]
|
||||
uri = "/webhooks/orders"
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
topics = ["products/update"]
|
||||
uri = "/webhooks/products"
|
||||
|
||||
# GDPR mandatory webhooks (required for app approval)
|
||||
[webhooks.privacy_compliance]
|
||||
customer_data_request_url = "/webhooks/gdpr/data-request"
|
||||
customer_deletion_url = "/webhooks/gdpr/customer-deletion"
|
||||
shop_deletion_url = "/webhooks/gdpr/shop-deletion"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### API Usage
|
||||
|
||||
- Use GraphQL over REST for new development
|
||||
- Request only fields you need (reduces query cost)
|
||||
- Implement cursor-based pagination with `pageInfo.endCursor`
|
||||
- Use bulk operations for processing more than 250 items
|
||||
- Handle rate limits with exponential backoff
|
||||
|
||||
### Security
|
||||
|
||||
- Store API credentials in environment variables
|
||||
- Always verify webhook HMAC signatures before processing
|
||||
- Validate OAuth state parameter to prevent CSRF
|
||||
- Request minimal access scopes
|
||||
- Use session tokens for embedded apps
|
||||
|
||||
### Performance
|
||||
|
||||
- Cache API responses when data doesn't change frequently
|
||||
- Use lazy loading in extensions
|
||||
- Optimize images in themes using `img_url` filter
|
||||
- Monitor GraphQL query costs via response headers
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**IF you see rate limit errors:**
|
||||
→ Implement exponential backoff retry logic
|
||||
→ Switch to bulk operations for large datasets
|
||||
→ Monitor `X-Shopify-Shop-Api-Call-Limit` header
|
||||
|
||||
**IF authentication fails:**
|
||||
→ Verify the access token is still valid
|
||||
→ Check that all required scopes were granted
|
||||
→ Ensure OAuth flow completed successfully
|
||||
|
||||
**IF extension is not appearing:**
|
||||
→ Verify the extension target is correct
|
||||
→ Check that extension is published via `shopify app deploy`
|
||||
→ Confirm the app is installed on the test store
|
||||
|
||||
**IF webhook is not receiving events:**
|
||||
→ Verify the webhook URL is publicly accessible
|
||||
→ Check HMAC signature validation logic
|
||||
→ Review webhook logs in Partner Dashboard
|
||||
|
||||
**IF GraphQL query fails:**
|
||||
→ Validate query against schema (use GraphiQL explorer)
|
||||
→ Check for deprecated fields in error message
|
||||
→ Verify you have required access scopes
|
||||
|
||||
---
|
||||
|
||||
## Reference Files
|
||||
|
||||
For detailed implementation guides, read these files:
|
||||
|
||||
- `references/app-development.md` - OAuth authentication flow, GraphQL mutations for products/orders/billing, webhook handlers, billing API integration
|
||||
- `references/extensions.md` - Checkout UI components, Admin UI extensions, POS extensions, Shopify Functions for discounts/payment/delivery
|
||||
- `references/themes.md` - Liquid syntax reference, theme directory structure, sections and snippets, common patterns
|
||||
|
||||
---
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/shopify_init.py` - Interactive project scaffolding. Run: `python scripts/shopify_init.py`
|
||||
- `scripts/shopify_graphql.py` - GraphQL utilities with query templates, pagination, rate limiting. Import: `from shopify_graphql import ShopifyGraphQL`
|
||||
|
||||
---
|
||||
|
||||
## Official Documentation Links
|
||||
|
||||
- Shopify Developer Docs: https://shopify.dev/docs
|
||||
- GraphQL Admin API Reference: https://shopify.dev/docs/api/admin-graphql
|
||||
- Shopify CLI Reference: https://shopify.dev/docs/api/shopify-cli
|
||||
- Polaris Design System: https://polaris.shopify.com
|
||||
|
||||
API Version: 2026-01 (quarterly releases, 12-month deprecation window)
|
||||
578
skills/shopify-development/references/app-development.md
Normal file
578
skills/shopify-development/references/app-development.md
Normal file
@@ -0,0 +1,578 @@
|
||||
# App Development Reference
|
||||
|
||||
Guide for building Shopify apps with OAuth, GraphQL/REST APIs, webhooks, and billing.
|
||||
|
||||
## OAuth Authentication
|
||||
|
||||
### OAuth 2.0 Flow
|
||||
|
||||
**1. Redirect to Authorization URL:**
|
||||
|
||||
```
|
||||
https://{shop}.myshopify.com/admin/oauth/authorize?
|
||||
client_id={api_key}&
|
||||
scope={scopes}&
|
||||
redirect_uri={redirect_uri}&
|
||||
state={nonce}
|
||||
```
|
||||
|
||||
**2. Handle Callback:**
|
||||
|
||||
```javascript
|
||||
app.get("/auth/callback", async (req, res) => {
|
||||
const { code, shop, state } = req.query;
|
||||
|
||||
// Verify state to prevent CSRF
|
||||
if (state !== storedState) {
|
||||
return res.status(403).send("Invalid state");
|
||||
}
|
||||
|
||||
// Exchange code for access token
|
||||
const accessToken = await exchangeCodeForToken(shop, code);
|
||||
|
||||
// Store token securely
|
||||
await storeAccessToken(shop, accessToken);
|
||||
|
||||
res.redirect(`https://${shop}/admin/apps/${appHandle}`);
|
||||
});
|
||||
```
|
||||
|
||||
**3. Exchange Code for Token:**
|
||||
|
||||
```javascript
|
||||
async function exchangeCodeForToken(shop, code) {
|
||||
const response = await fetch(`https://${shop}/admin/oauth/access_token`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
client_id: process.env.SHOPIFY_API_KEY,
|
||||
client_secret: process.env.SHOPIFY_API_SECRET,
|
||||
code,
|
||||
}),
|
||||
});
|
||||
|
||||
const { access_token } = await response.json();
|
||||
return access_token;
|
||||
}
|
||||
```
|
||||
|
||||
### Access Scopes
|
||||
|
||||
**Common Scopes:**
|
||||
|
||||
- `read_products`, `write_products` - Product catalog
|
||||
- `read_orders`, `write_orders` - Order management
|
||||
- `read_customers`, `write_customers` - Customer data
|
||||
- `read_inventory`, `write_inventory` - Stock levels
|
||||
- `read_fulfillments`, `write_fulfillments` - Order fulfillment
|
||||
- `read_shipping`, `write_shipping` - Shipping rates
|
||||
- `read_analytics` - Store analytics
|
||||
- `read_checkouts`, `write_checkouts` - Checkout data
|
||||
|
||||
Full list: https://shopify.dev/api/usage/access-scopes
|
||||
|
||||
### Session Tokens (Embedded Apps)
|
||||
|
||||
For embedded apps using App Bridge:
|
||||
|
||||
```javascript
|
||||
import { getSessionToken } from '@shopify/app-bridge/utilities';
|
||||
|
||||
async function authenticatedFetch(url, options = {}) {
|
||||
const app = createApp({ ... });
|
||||
const token = await getSessionToken(app);
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## GraphQL Admin API
|
||||
|
||||
### Making Requests
|
||||
|
||||
```javascript
|
||||
async function graphqlRequest(shop, accessToken, query, variables = {}) {
|
||||
const response = await fetch(
|
||||
`https://${shop}/admin/api/2026-01/graphql.json`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-Shopify-Access-Token": accessToken,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.errors) {
|
||||
throw new Error(`GraphQL errors: ${JSON.stringify(data.errors)}`);
|
||||
}
|
||||
|
||||
return data.data;
|
||||
}
|
||||
```
|
||||
|
||||
### Product Operations
|
||||
|
||||
**Create Product:**
|
||||
|
||||
```graphql
|
||||
mutation CreateProduct($input: ProductInput!) {
|
||||
productCreate(input: $input) {
|
||||
product {
|
||||
id
|
||||
title
|
||||
handle
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Variables:
|
||||
|
||||
```json
|
||||
{
|
||||
"input": {
|
||||
"title": "New Product",
|
||||
"productType": "Apparel",
|
||||
"vendor": "Brand",
|
||||
"status": "ACTIVE",
|
||||
"variants": [
|
||||
{ "price": "29.99", "sku": "SKU-001", "inventoryQuantity": 100 }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Update Product:**
|
||||
|
||||
```graphql
|
||||
mutation UpdateProduct($input: ProductInput!) {
|
||||
productUpdate(input: $input) {
|
||||
product {
|
||||
id
|
||||
title
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Query Products:**
|
||||
|
||||
```graphql
|
||||
query GetProducts($first: Int!, $query: String) {
|
||||
products(first: $first, query: $query) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
status
|
||||
variants(first: 5) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
price
|
||||
inventoryQuantity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Order Operations
|
||||
|
||||
**Query Orders:**
|
||||
|
||||
```graphql
|
||||
query GetOrders($first: Int!) {
|
||||
orders(first: $first) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
createdAt
|
||||
displayFinancialStatus
|
||||
totalPriceSet {
|
||||
shopMoney {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
customer {
|
||||
email
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fulfill Order:**
|
||||
|
||||
```graphql
|
||||
mutation FulfillOrder($fulfillment: FulfillmentInput!) {
|
||||
fulfillmentCreate(fulfillment: $fulfillment) {
|
||||
fulfillment {
|
||||
id
|
||||
status
|
||||
trackingInfo {
|
||||
number
|
||||
url
|
||||
}
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Webhooks
|
||||
|
||||
### Configuration
|
||||
|
||||
In `shopify.app.toml`:
|
||||
|
||||
```toml
|
||||
[webhooks]
|
||||
api_version = "2025-01"
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
topics = ["orders/create"]
|
||||
uri = "/webhooks/orders/create"
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
topics = ["products/update"]
|
||||
uri = "/webhooks/products/update"
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
topics = ["app/uninstalled"]
|
||||
uri = "/webhooks/app/uninstalled"
|
||||
|
||||
# GDPR mandatory webhooks
|
||||
[webhooks.privacy_compliance]
|
||||
customer_data_request_url = "/webhooks/gdpr/data-request"
|
||||
customer_deletion_url = "/webhooks/gdpr/customer-deletion"
|
||||
shop_deletion_url = "/webhooks/gdpr/shop-deletion"
|
||||
```
|
||||
|
||||
### Webhook Handler
|
||||
|
||||
```javascript
|
||||
import crypto from "crypto";
|
||||
|
||||
function verifyWebhook(req) {
|
||||
const hmac = req.headers["x-shopify-hmac-sha256"];
|
||||
const body = req.rawBody; // Raw body buffer
|
||||
|
||||
const hash = crypto
|
||||
.createHmac("sha256", process.env.SHOPIFY_API_SECRET)
|
||||
.update(body, "utf8")
|
||||
.digest("base64");
|
||||
|
||||
return hmac === hash;
|
||||
}
|
||||
|
||||
app.post("/webhooks/orders/create", async (req, res) => {
|
||||
if (!verifyWebhook(req)) {
|
||||
return res.status(401).send("Unauthorized");
|
||||
}
|
||||
|
||||
const order = req.body;
|
||||
console.log("New order:", order.id, order.name);
|
||||
|
||||
// Process order...
|
||||
|
||||
res.status(200).send("OK");
|
||||
});
|
||||
```
|
||||
|
||||
### Common Webhook Topics
|
||||
|
||||
**Orders:**
|
||||
|
||||
- `orders/create`, `orders/updated`, `orders/delete`
|
||||
- `orders/paid`, `orders/cancelled`, `orders/fulfilled`
|
||||
|
||||
**Products:**
|
||||
|
||||
- `products/create`, `products/update`, `products/delete`
|
||||
|
||||
**Customers:**
|
||||
|
||||
- `customers/create`, `customers/update`, `customers/delete`
|
||||
|
||||
**Inventory:**
|
||||
|
||||
- `inventory_levels/update`
|
||||
|
||||
**App:**
|
||||
|
||||
- `app/uninstalled` (critical for cleanup)
|
||||
|
||||
## Billing Integration
|
||||
|
||||
### App Charges
|
||||
|
||||
**One-time Charge:**
|
||||
|
||||
```graphql
|
||||
mutation CreateCharge($input: AppPurchaseOneTimeInput!) {
|
||||
appPurchaseOneTimeCreate(input: $input) {
|
||||
appPurchaseOneTime {
|
||||
id
|
||||
name
|
||||
price {
|
||||
amount
|
||||
}
|
||||
status
|
||||
confirmationUrl
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Variables:
|
||||
|
||||
```json
|
||||
{
|
||||
"input": {
|
||||
"name": "Premium Feature",
|
||||
"price": { "amount": 49.99, "currencyCode": "USD" },
|
||||
"returnUrl": "https://your-app.com/billing/callback"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Recurring Charge (Subscription):**
|
||||
|
||||
```graphql
|
||||
mutation CreateSubscription(
|
||||
$name: String!
|
||||
$returnUrl: URL!
|
||||
$lineItems: [AppSubscriptionLineItemInput!]!
|
||||
$trialDays: Int
|
||||
) {
|
||||
appSubscriptionCreate(
|
||||
name: $name
|
||||
returnUrl: $returnUrl
|
||||
lineItems: $lineItems
|
||||
trialDays: $trialDays
|
||||
) {
|
||||
appSubscription {
|
||||
id
|
||||
name
|
||||
status
|
||||
}
|
||||
confirmationUrl
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Variables:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Monthly Subscription",
|
||||
"returnUrl": "https://your-app.com/billing/callback",
|
||||
"trialDays": 7,
|
||||
"lineItems": [
|
||||
{
|
||||
"plan": {
|
||||
"appRecurringPricingDetails": {
|
||||
"price": { "amount": 29.99, "currencyCode": "USD" },
|
||||
"interval": "EVERY_30_DAYS"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Usage-based Billing:**
|
||||
|
||||
```graphql
|
||||
mutation CreateUsageCharge(
|
||||
$subscriptionLineItemId: ID!
|
||||
$price: MoneyInput!
|
||||
$description: String!
|
||||
) {
|
||||
appUsageRecordCreate(
|
||||
subscriptionLineItemId: $subscriptionLineItemId
|
||||
price: $price
|
||||
description: $description
|
||||
) {
|
||||
appUsageRecord {
|
||||
id
|
||||
price {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
description
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Variables:
|
||||
|
||||
```json
|
||||
{
|
||||
"subscriptionLineItemId": "gid://shopify/AppSubscriptionLineItem/123",
|
||||
"price": { "amount": "5.00", "currencyCode": "USD" },
|
||||
"description": "100 API calls used"
|
||||
}
|
||||
```
|
||||
|
||||
## Metafields
|
||||
|
||||
### Create/Update Metafields
|
||||
|
||||
```graphql
|
||||
mutation SetMetafields($metafields: [MetafieldsSetInput!]!) {
|
||||
metafieldsSet(metafields: $metafields) {
|
||||
metafields {
|
||||
id
|
||||
namespace
|
||||
key
|
||||
value
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Variables:
|
||||
|
||||
```json
|
||||
{
|
||||
"metafields": [
|
||||
{
|
||||
"ownerId": "gid://shopify/Product/123",
|
||||
"namespace": "custom",
|
||||
"key": "instructions",
|
||||
"value": "Handle with care",
|
||||
"type": "single_line_text_field"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Metafield Types:**
|
||||
|
||||
- `single_line_text_field`, `multi_line_text_field`
|
||||
- `number_integer`, `number_decimal`
|
||||
- `date`, `date_time`
|
||||
- `url`, `json`
|
||||
- `file_reference`, `product_reference`
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
### GraphQL Cost-Based Limits
|
||||
|
||||
**Limits:**
|
||||
|
||||
- Available points: 2000
|
||||
- Restore rate: 100 points/second
|
||||
- Max query cost: 2000
|
||||
|
||||
**Check Cost:**
|
||||
|
||||
```javascript
|
||||
const response = await graphqlRequest(shop, token, query);
|
||||
const cost = response.extensions?.cost;
|
||||
|
||||
console.log(
|
||||
`Cost: ${cost.actualQueryCost}/${cost.throttleStatus.maximumAvailable}`,
|
||||
);
|
||||
```
|
||||
|
||||
**Handle Throttling:**
|
||||
|
||||
```javascript
|
||||
async function graphqlWithRetry(shop, token, query, retries = 3) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
return await graphqlRequest(shop, token, query);
|
||||
} catch (error) {
|
||||
if (error.message.includes("Throttled") && i < retries - 1) {
|
||||
await sleep(Math.pow(2, i) * 1000); // Exponential backoff
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
**Security:**
|
||||
|
||||
- Store credentials in environment variables
|
||||
- Verify webhook HMAC signatures
|
||||
- Validate OAuth state parameter
|
||||
- Use HTTPS for all endpoints
|
||||
- Implement rate limiting on your endpoints
|
||||
|
||||
**Performance:**
|
||||
|
||||
- Cache access tokens securely
|
||||
- Use bulk operations for large datasets
|
||||
- Implement pagination for queries
|
||||
- Monitor GraphQL query costs
|
||||
|
||||
**Reliability:**
|
||||
|
||||
- Implement exponential backoff for retries
|
||||
- Handle webhook delivery failures
|
||||
- Log errors for debugging
|
||||
- Monitor app health metrics
|
||||
|
||||
**Compliance:**
|
||||
|
||||
- Implement GDPR webhooks (mandatory)
|
||||
- Handle customer data deletion requests
|
||||
- Provide data export functionality
|
||||
- Follow data retention policies
|
||||
555
skills/shopify-development/references/extensions.md
Normal file
555
skills/shopify-development/references/extensions.md
Normal file
@@ -0,0 +1,555 @@
|
||||
# Extensions Reference
|
||||
|
||||
Guide for building UI extensions and Shopify Functions.
|
||||
|
||||
## Checkout UI Extensions
|
||||
|
||||
Customize checkout and thank-you pages with native-rendered components.
|
||||
|
||||
### Extension Points
|
||||
|
||||
**Block Targets (Merchant-Configurable):**
|
||||
|
||||
- `purchase.checkout.block.render` - Main checkout
|
||||
- `purchase.thank-you.block.render` - Thank you page
|
||||
|
||||
**Static Targets (Fixed Position):**
|
||||
|
||||
- `purchase.checkout.header.render-after`
|
||||
- `purchase.checkout.contact.render-before`
|
||||
- `purchase.checkout.shipping-option-list.render-after`
|
||||
- `purchase.checkout.payment-method-list.render-after`
|
||||
- `purchase.checkout.footer.render-before`
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
shopify app generate extension --type checkout_ui_extension
|
||||
```
|
||||
|
||||
Configuration (`shopify.extension.toml`):
|
||||
|
||||
```toml
|
||||
api_version = "2026-01"
|
||||
name = "gift-message"
|
||||
type = "ui_extension"
|
||||
|
||||
[[extensions.targeting]]
|
||||
target = "purchase.checkout.block.render"
|
||||
|
||||
[capabilities]
|
||||
network_access = true
|
||||
api_access = true
|
||||
```
|
||||
|
||||
### Basic Example
|
||||
|
||||
```javascript
|
||||
import {
|
||||
reactExtension,
|
||||
BlockStack,
|
||||
TextField,
|
||||
Checkbox,
|
||||
useApi,
|
||||
} from "@shopify/ui-extensions-react/checkout";
|
||||
|
||||
export default reactExtension("purchase.checkout.block.render", () => (
|
||||
<Extension />
|
||||
));
|
||||
|
||||
function Extension() {
|
||||
const [message, setMessage] = useState("");
|
||||
const [isGift, setIsGift] = useState(false);
|
||||
const { applyAttributeChange } = useApi();
|
||||
|
||||
useEffect(() => {
|
||||
if (isGift) {
|
||||
applyAttributeChange({
|
||||
type: "updateAttribute",
|
||||
key: "gift_message",
|
||||
value: message,
|
||||
});
|
||||
}
|
||||
}, [message, isGift]);
|
||||
|
||||
return (
|
||||
<BlockStack spacing="loose">
|
||||
<Checkbox checked={isGift} onChange={setIsGift}>
|
||||
This is a gift
|
||||
</Checkbox>
|
||||
{isGift && (
|
||||
<TextField
|
||||
label="Gift Message"
|
||||
value={message}
|
||||
onChange={setMessage}
|
||||
multiline={3}
|
||||
/>
|
||||
)}
|
||||
</BlockStack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Common Hooks
|
||||
|
||||
**useApi:**
|
||||
|
||||
```javascript
|
||||
const { extensionPoint, shop, storefront, i18n, sessionToken } = useApi();
|
||||
```
|
||||
|
||||
**useCartLines:**
|
||||
|
||||
```javascript
|
||||
const lines = useCartLines();
|
||||
lines.forEach((line) => {
|
||||
console.log(line.merchandise.product.title, line.quantity);
|
||||
});
|
||||
```
|
||||
|
||||
**useShippingAddress:**
|
||||
|
||||
```javascript
|
||||
const address = useShippingAddress();
|
||||
console.log(address.city, address.countryCode);
|
||||
```
|
||||
|
||||
**useApplyCartLinesChange:**
|
||||
|
||||
```javascript
|
||||
const applyChange = useApplyCartLinesChange();
|
||||
|
||||
async function addItem() {
|
||||
await applyChange({
|
||||
type: "addCartLine",
|
||||
merchandiseId: "gid://shopify/ProductVariant/123",
|
||||
quantity: 1,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Core Components
|
||||
|
||||
**Layout:**
|
||||
|
||||
- `BlockStack` - Vertical stacking
|
||||
- `InlineStack` - Horizontal layout
|
||||
- `Grid`, `GridItem` - Grid layout
|
||||
- `View` - Container
|
||||
- `Divider` - Separator
|
||||
|
||||
**Input:**
|
||||
|
||||
- `TextField` - Text input
|
||||
- `Checkbox` - Boolean
|
||||
- `Select` - Dropdown
|
||||
- `DatePicker` - Date selection
|
||||
- `Form` - Form wrapper
|
||||
|
||||
**Display:**
|
||||
|
||||
- `Text`, `Heading` - Typography
|
||||
- `Banner` - Messages
|
||||
- `Badge` - Status
|
||||
- `Image` - Images
|
||||
- `Link` - Hyperlinks
|
||||
- `List`, `ListItem` - Lists
|
||||
|
||||
**Interactive:**
|
||||
|
||||
- `Button` - Actions
|
||||
- `Modal` - Overlays
|
||||
- `Pressable` - Click areas
|
||||
|
||||
## Admin UI Extensions
|
||||
|
||||
Extend Shopify admin interface.
|
||||
|
||||
### Admin Action
|
||||
|
||||
Custom actions on resource pages.
|
||||
|
||||
```bash
|
||||
shopify app generate extension --type admin_action
|
||||
```
|
||||
|
||||
```javascript
|
||||
import {
|
||||
reactExtension,
|
||||
AdminAction,
|
||||
Button,
|
||||
} from "@shopify/ui-extensions-react/admin";
|
||||
|
||||
export default reactExtension("admin.product-details.action.render", () => (
|
||||
<Extension />
|
||||
));
|
||||
|
||||
function Extension() {
|
||||
const { data } = useData();
|
||||
|
||||
async function handleExport() {
|
||||
const response = await fetch("/api/export", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ productId: data.product.id }),
|
||||
});
|
||||
console.log("Exported:", await response.json());
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminAction
|
||||
title="Export Product"
|
||||
primaryAction={<Button onPress={handleExport}>Export</Button>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Targets:**
|
||||
|
||||
- `admin.product-details.action.render`
|
||||
- `admin.order-details.action.render`
|
||||
- `admin.customer-details.action.render`
|
||||
|
||||
### Admin Block
|
||||
|
||||
Embedded content in admin pages.
|
||||
|
||||
```javascript
|
||||
import {
|
||||
reactExtension,
|
||||
BlockStack,
|
||||
Text,
|
||||
Badge,
|
||||
} from "@shopify/ui-extensions-react/admin";
|
||||
|
||||
export default reactExtension("admin.product-details.block.render", () => (
|
||||
<Extension />
|
||||
));
|
||||
|
||||
function Extension() {
|
||||
const { data } = useData();
|
||||
const [analytics, setAnalytics] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics(data.product.id).then(setAnalytics);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BlockStack>
|
||||
<Text variant="headingMd">Product Analytics</Text>
|
||||
<Text>Views: {analytics?.views || 0}</Text>
|
||||
<Text>Conversions: {analytics?.conversions || 0}</Text>
|
||||
<Badge tone={analytics?.trending ? "success" : "info"}>
|
||||
{analytics?.trending ? "Trending" : "Normal"}
|
||||
</Badge>
|
||||
</BlockStack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Targets:**
|
||||
|
||||
- `admin.product-details.block.render`
|
||||
- `admin.order-details.block.render`
|
||||
- `admin.customer-details.block.render`
|
||||
|
||||
## POS UI Extensions
|
||||
|
||||
Customize Point of Sale experience.
|
||||
|
||||
### Smart Grid Tile
|
||||
|
||||
Quick access action on POS home screen.
|
||||
|
||||
```javascript
|
||||
import {
|
||||
reactExtension,
|
||||
SmartGridTile,
|
||||
} from "@shopify/ui-extensions-react/pos";
|
||||
|
||||
export default reactExtension("pos.home.tile.render", () => <Extension />);
|
||||
|
||||
function Extension() {
|
||||
function handlePress() {
|
||||
// Navigate to custom workflow
|
||||
}
|
||||
|
||||
return (
|
||||
<SmartGridTile
|
||||
title="Gift Cards"
|
||||
subtitle="Manage gift cards"
|
||||
onPress={handlePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### POS Modal
|
||||
|
||||
Full-screen workflow.
|
||||
|
||||
```javascript
|
||||
import {
|
||||
reactExtension,
|
||||
Screen,
|
||||
BlockStack,
|
||||
Button,
|
||||
TextField,
|
||||
} from "@shopify/ui-extensions-react/pos";
|
||||
|
||||
export default reactExtension("pos.home.modal.render", () => <Extension />);
|
||||
|
||||
function Extension() {
|
||||
const { navigation } = useApi();
|
||||
const [amount, setAmount] = useState("");
|
||||
|
||||
function handleIssue() {
|
||||
// Issue gift card
|
||||
navigation.pop();
|
||||
}
|
||||
|
||||
return (
|
||||
<Screen name="Gift Card" title="Issue Gift Card">
|
||||
<BlockStack>
|
||||
<TextField label="Amount" value={amount} onChange={setAmount} />
|
||||
<TextField label="Recipient Email" />
|
||||
<Button onPress={handleIssue}>Issue</Button>
|
||||
</BlockStack>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Customer Account Extensions
|
||||
|
||||
Customize customer account pages.
|
||||
|
||||
### Order Status Extension
|
||||
|
||||
```javascript
|
||||
import {
|
||||
reactExtension,
|
||||
BlockStack,
|
||||
Text,
|
||||
Button,
|
||||
} from "@shopify/ui-extensions-react/customer-account";
|
||||
|
||||
export default reactExtension(
|
||||
"customer-account.order-status.block.render",
|
||||
() => <Extension />,
|
||||
);
|
||||
|
||||
function Extension() {
|
||||
const { order } = useApi();
|
||||
|
||||
function handleReturn() {
|
||||
// Initiate return
|
||||
}
|
||||
|
||||
return (
|
||||
<BlockStack>
|
||||
<Text variant="headingMd">Need to return?</Text>
|
||||
<Text>Start return for order {order.name}</Text>
|
||||
<Button onPress={handleReturn}>Start Return</Button>
|
||||
</BlockStack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Targets:**
|
||||
|
||||
- `customer-account.order-status.block.render`
|
||||
- `customer-account.order-index.block.render`
|
||||
- `customer-account.profile.block.render`
|
||||
|
||||
## Shopify Functions
|
||||
|
||||
Serverless backend customization.
|
||||
|
||||
### Function Types
|
||||
|
||||
**Discounts:**
|
||||
|
||||
- `order_discount` - Order-level discounts
|
||||
- `product_discount` - Product-specific discounts
|
||||
- `shipping_discount` - Shipping discounts
|
||||
|
||||
**Payment Customization:**
|
||||
|
||||
- Hide/rename/reorder payment methods
|
||||
|
||||
**Delivery Customization:**
|
||||
|
||||
- Custom shipping options
|
||||
- Delivery rules
|
||||
|
||||
**Validation:**
|
||||
|
||||
- Cart validation rules
|
||||
- Checkout validation
|
||||
|
||||
### Create Function
|
||||
|
||||
```bash
|
||||
shopify app generate extension --type function
|
||||
```
|
||||
|
||||
### Order Discount Function
|
||||
|
||||
```javascript
|
||||
// input.graphql
|
||||
query Input {
|
||||
cart {
|
||||
lines {
|
||||
quantity
|
||||
merchandise {
|
||||
... on ProductVariant {
|
||||
product {
|
||||
hasTag(tag: "bulk-discount")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// function.js
|
||||
export default function orderDiscount(input) {
|
||||
const targets = input.cart.lines
|
||||
.filter(line => line.merchandise.product.hasTag)
|
||||
.map(line => ({
|
||||
productVariant: { id: line.merchandise.id }
|
||||
}));
|
||||
|
||||
if (targets.length === 0) {
|
||||
return { discounts: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
discounts: [{
|
||||
targets,
|
||||
value: {
|
||||
percentage: {
|
||||
value: 10 // 10% discount
|
||||
}
|
||||
}
|
||||
}]
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Payment Customization Function
|
||||
|
||||
```javascript
|
||||
export default function paymentCustomization(input) {
|
||||
const hidePaymentMethods = input.cart.lines.some(
|
||||
(line) => line.merchandise.product.hasTag,
|
||||
);
|
||||
|
||||
if (!hidePaymentMethods) {
|
||||
return { operations: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
operations: [
|
||||
{
|
||||
hide: {
|
||||
paymentMethodId: "gid://shopify/PaymentMethod/123",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Function
|
||||
|
||||
```javascript
|
||||
export default function cartValidation(input) {
|
||||
const errors = [];
|
||||
|
||||
// Max 5 items per cart
|
||||
if (input.cart.lines.length > 5) {
|
||||
errors.push({
|
||||
localizedMessage: "Maximum 5 items allowed per order",
|
||||
target: "cart",
|
||||
});
|
||||
}
|
||||
|
||||
// Min $50 for wholesale
|
||||
const isWholesale = input.cart.lines.some(
|
||||
(line) => line.merchandise.product.hasTag,
|
||||
);
|
||||
|
||||
if (isWholesale && input.cart.cost.totalAmount.amount < 50) {
|
||||
errors.push({
|
||||
localizedMessage: "Wholesale orders require $50 minimum",
|
||||
target: "cart",
|
||||
});
|
||||
}
|
||||
|
||||
return { errors };
|
||||
}
|
||||
```
|
||||
|
||||
## Network Requests
|
||||
|
||||
Extensions can call external APIs.
|
||||
|
||||
```javascript
|
||||
import { useApi } from "@shopify/ui-extensions-react/checkout";
|
||||
|
||||
function Extension() {
|
||||
const { sessionToken } = useApi();
|
||||
|
||||
async function fetchData() {
|
||||
const token = await sessionToken.get();
|
||||
|
||||
const response = await fetch("https://your-app.com/api/data", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
**Performance:**
|
||||
|
||||
- Lazy load data
|
||||
- Memoize expensive computations
|
||||
- Use loading states
|
||||
- Minimize re-renders
|
||||
|
||||
**UX:**
|
||||
|
||||
- Provide clear error messages
|
||||
- Show loading indicators
|
||||
- Validate inputs
|
||||
- Support keyboard navigation
|
||||
|
||||
**Security:**
|
||||
|
||||
- Verify session tokens on backend
|
||||
- Sanitize user input
|
||||
- Use HTTPS for all requests
|
||||
- Don't expose sensitive data
|
||||
|
||||
**Testing:**
|
||||
|
||||
- Test on development stores
|
||||
- Verify mobile/desktop
|
||||
- Check accessibility
|
||||
- Test edge cases
|
||||
|
||||
## Resources
|
||||
|
||||
- Checkout Extensions: https://shopify.dev/docs/api/checkout-extensions
|
||||
- Admin Extensions: https://shopify.dev/docs/apps/admin/extensions
|
||||
- Functions: https://shopify.dev/docs/apps/functions
|
||||
- Components: https://shopify.dev/docs/api/checkout-ui-extensions/components
|
||||
498
skills/shopify-development/references/themes.md
Normal file
498
skills/shopify-development/references/themes.md
Normal file
@@ -0,0 +1,498 @@
|
||||
# Themes Reference
|
||||
|
||||
Guide for developing Shopify themes with Liquid templating.
|
||||
|
||||
## Liquid Templating
|
||||
|
||||
### Syntax Basics
|
||||
|
||||
**Objects (Output):**
|
||||
```liquid
|
||||
{{ product.title }}
|
||||
{{ product.price | money }}
|
||||
{{ customer.email }}
|
||||
```
|
||||
|
||||
**Tags (Logic):**
|
||||
```liquid
|
||||
{% if product.available %}
|
||||
<button>Add to Cart</button>
|
||||
{% else %}
|
||||
<p>Sold Out</p>
|
||||
{% endif %}
|
||||
|
||||
{% for product in collection.products %}
|
||||
{{ product.title }}
|
||||
{% endfor %}
|
||||
|
||||
{% case product.type %}
|
||||
{% when 'Clothing' %}
|
||||
<span>Apparel</span>
|
||||
{% when 'Shoes' %}
|
||||
<span>Footwear</span>
|
||||
{% else %}
|
||||
<span>Other</span>
|
||||
{% endcase %}
|
||||
```
|
||||
|
||||
**Filters (Transform):**
|
||||
```liquid
|
||||
{{ product.title | upcase }}
|
||||
{{ product.price | money }}
|
||||
{{ product.description | strip_html | truncate: 100 }}
|
||||
{{ product.image | img_url: 'medium' }}
|
||||
{{ 'now' | date: '%B %d, %Y' }}
|
||||
```
|
||||
|
||||
### Common Objects
|
||||
|
||||
**Product:**
|
||||
```liquid
|
||||
{{ product.id }}
|
||||
{{ product.title }}
|
||||
{{ product.handle }}
|
||||
{{ product.description }}
|
||||
{{ product.price }}
|
||||
{{ product.compare_at_price }}
|
||||
{{ product.available }}
|
||||
{{ product.type }}
|
||||
{{ product.vendor }}
|
||||
{{ product.tags }}
|
||||
{{ product.images }}
|
||||
{{ product.variants }}
|
||||
{{ product.featured_image }}
|
||||
{{ product.url }}
|
||||
```
|
||||
|
||||
**Collection:**
|
||||
```liquid
|
||||
{{ collection.title }}
|
||||
{{ collection.handle }}
|
||||
{{ collection.description }}
|
||||
{{ collection.products }}
|
||||
{{ collection.products_count }}
|
||||
{{ collection.image }}
|
||||
{{ collection.url }}
|
||||
```
|
||||
|
||||
**Cart:**
|
||||
```liquid
|
||||
{{ cart.item_count }}
|
||||
{{ cart.total_price }}
|
||||
{{ cart.items }}
|
||||
{{ cart.note }}
|
||||
{{ cart.attributes }}
|
||||
```
|
||||
|
||||
**Customer:**
|
||||
```liquid
|
||||
{{ customer.email }}
|
||||
{{ customer.first_name }}
|
||||
{{ customer.last_name }}
|
||||
{{ customer.orders_count }}
|
||||
{{ customer.total_spent }}
|
||||
{{ customer.addresses }}
|
||||
{{ customer.default_address }}
|
||||
```
|
||||
|
||||
**Shop:**
|
||||
```liquid
|
||||
{{ shop.name }}
|
||||
{{ shop.email }}
|
||||
{{ shop.domain }}
|
||||
{{ shop.currency }}
|
||||
{{ shop.money_format }}
|
||||
{{ shop.enabled_payment_types }}
|
||||
```
|
||||
|
||||
### Common Filters
|
||||
|
||||
**String:**
|
||||
- `upcase`, `downcase`, `capitalize`
|
||||
- `strip_html`, `strip_newlines`
|
||||
- `truncate: 100`, `truncatewords: 20`
|
||||
- `replace: 'old', 'new'`
|
||||
|
||||
**Number:**
|
||||
- `money` - Format currency
|
||||
- `round`, `ceil`, `floor`
|
||||
- `times`, `divided_by`, `plus`, `minus`
|
||||
|
||||
**Array:**
|
||||
- `join: ', '`
|
||||
- `first`, `last`
|
||||
- `size`
|
||||
- `map: 'property'`
|
||||
- `where: 'property', 'value'`
|
||||
|
||||
**URL:**
|
||||
- `img_url: 'size'` - Image URL
|
||||
- `url_for_type`, `url_for_vendor`
|
||||
- `link_to`, `link_to_type`
|
||||
|
||||
**Date:**
|
||||
- `date: '%B %d, %Y'`
|
||||
|
||||
## Theme Architecture
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
theme/
|
||||
├── assets/ # CSS, JS, images
|
||||
├── config/ # Theme settings
|
||||
│ ├── settings_schema.json
|
||||
│ └── settings_data.json
|
||||
├── layout/ # Base templates
|
||||
│ └── theme.liquid
|
||||
├── locales/ # Translations
|
||||
│ └── en.default.json
|
||||
├── sections/ # Reusable blocks
|
||||
│ ├── header.liquid
|
||||
│ ├── footer.liquid
|
||||
│ └── product-grid.liquid
|
||||
├── snippets/ # Small components
|
||||
│ ├── product-card.liquid
|
||||
│ └── icon.liquid
|
||||
└── templates/ # Page templates
|
||||
├── index.json
|
||||
├── product.json
|
||||
├── collection.json
|
||||
└── cart.liquid
|
||||
```
|
||||
|
||||
### Layout
|
||||
|
||||
Base template wrapping all pages (`layout/theme.liquid`):
|
||||
|
||||
```liquid
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ request.locale.iso_code }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{ page_title }}</title>
|
||||
|
||||
{{ content_for_header }}
|
||||
|
||||
<link rel="stylesheet" href="{{ 'theme.css' | asset_url }}">
|
||||
</head>
|
||||
<body>
|
||||
{% section 'header' %}
|
||||
|
||||
<main>
|
||||
{{ content_for_layout }}
|
||||
</main>
|
||||
|
||||
{% section 'footer' %}
|
||||
|
||||
<script src="{{ 'theme.js' | asset_url }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Templates
|
||||
|
||||
Page-specific structures (`templates/product.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"sections": {
|
||||
"main": {
|
||||
"type": "product-template",
|
||||
"settings": {
|
||||
"show_vendor": true,
|
||||
"show_quantity_selector": true
|
||||
}
|
||||
},
|
||||
"recommendations": {
|
||||
"type": "product-recommendations"
|
||||
}
|
||||
},
|
||||
"order": ["main", "recommendations"]
|
||||
}
|
||||
```
|
||||
|
||||
Legacy format (`templates/product.liquid`):
|
||||
```liquid
|
||||
<div class="product">
|
||||
<div class="product-images">
|
||||
<img src="{{ product.featured_image | img_url: 'large' }}" alt="{{ product.title }}">
|
||||
</div>
|
||||
|
||||
<div class="product-details">
|
||||
<h1>{{ product.title }}</h1>
|
||||
<p class="price">{{ product.price | money }}</p>
|
||||
|
||||
{% form 'product', product %}
|
||||
<select name="id">
|
||||
{% for variant in product.variants %}
|
||||
<option value="{{ variant.id }}">{{ variant.title }} - {{ variant.price | money }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
<button type="submit">Add to Cart</button>
|
||||
{% endform %}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Sections
|
||||
|
||||
Reusable content blocks (`sections/product-grid.liquid`):
|
||||
|
||||
```liquid
|
||||
<div class="product-grid">
|
||||
{% for product in section.settings.collection.products %}
|
||||
<div class="product-card">
|
||||
<a href="{{ product.url }}">
|
||||
<img src="{{ product.featured_image | img_url: 'medium' }}" alt="{{ product.title }}">
|
||||
<h3>{{ product.title }}</h3>
|
||||
<p>{{ product.price | money }}</p>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% schema %}
|
||||
{
|
||||
"name": "Product Grid",
|
||||
"settings": [
|
||||
{
|
||||
"type": "collection",
|
||||
"id": "collection",
|
||||
"label": "Collection"
|
||||
},
|
||||
{
|
||||
"type": "range",
|
||||
"id": "products_per_row",
|
||||
"min": 2,
|
||||
"max": 5,
|
||||
"step": 1,
|
||||
"default": 4,
|
||||
"label": "Products per row"
|
||||
}
|
||||
],
|
||||
"presets": [
|
||||
{
|
||||
"name": "Product Grid"
|
||||
}
|
||||
]
|
||||
}
|
||||
{% endschema %}
|
||||
```
|
||||
|
||||
### Snippets
|
||||
|
||||
Small reusable components (`snippets/product-card.liquid`):
|
||||
|
||||
```liquid
|
||||
<div class="product-card">
|
||||
<a href="{{ product.url }}">
|
||||
{% if product.featured_image %}
|
||||
<img src="{{ product.featured_image | img_url: 'medium' }}" alt="{{ product.title }}">
|
||||
{% endif %}
|
||||
<h3>{{ product.title }}</h3>
|
||||
<p class="price">{{ product.price | money }}</p>
|
||||
{% if product.compare_at_price > product.price %}
|
||||
<p class="sale-price">{{ product.compare_at_price | money }}</p>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
```
|
||||
|
||||
Include snippet:
|
||||
```liquid
|
||||
{% render 'product-card', product: product %}
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Initialize new theme
|
||||
shopify theme init
|
||||
|
||||
# Choose Dawn (reference theme) or blank
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
```bash
|
||||
# Start local server
|
||||
shopify theme dev
|
||||
|
||||
# Preview at http://localhost:9292
|
||||
# Changes auto-sync to development theme
|
||||
```
|
||||
|
||||
### Pull Theme
|
||||
|
||||
```bash
|
||||
# Pull live theme
|
||||
shopify theme pull --live
|
||||
|
||||
# Pull specific theme
|
||||
shopify theme pull --theme=123456789
|
||||
|
||||
# Pull only templates
|
||||
shopify theme pull --only=templates
|
||||
```
|
||||
|
||||
### Push Theme
|
||||
|
||||
```bash
|
||||
# Push to development theme
|
||||
shopify theme push --development
|
||||
|
||||
# Create new unpublished theme
|
||||
shopify theme push --unpublished
|
||||
|
||||
# Push specific files
|
||||
shopify theme push --only=sections,snippets
|
||||
```
|
||||
|
||||
### Theme Check
|
||||
|
||||
Lint theme code:
|
||||
```bash
|
||||
shopify theme check
|
||||
shopify theme check --auto-correct
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Product Form with Variants
|
||||
|
||||
```liquid
|
||||
{% form 'product', product %}
|
||||
{% unless product.has_only_default_variant %}
|
||||
{% for option in product.options_with_values %}
|
||||
<div class="product-option">
|
||||
<label>{{ option.name }}</label>
|
||||
<select name="options[{{ option.name }}]">
|
||||
{% for value in option.values %}
|
||||
<option value="{{ value }}">{{ value }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endunless %}
|
||||
|
||||
<input type="hidden" name="id" value="{{ product.selected_or_first_available_variant.id }}">
|
||||
<input type="number" name="quantity" value="1" min="1">
|
||||
|
||||
<button type="submit" {% unless product.available %}disabled{% endunless %}>
|
||||
{% if product.available %}Add to Cart{% else %}Sold Out{% endif %}
|
||||
</button>
|
||||
{% endform %}
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
```liquid
|
||||
{% paginate collection.products by 12 %}
|
||||
{% for product in collection.products %}
|
||||
{% render 'product-card', product: product %}
|
||||
{% endfor %}
|
||||
|
||||
{% if paginate.pages > 1 %}
|
||||
<div class="pagination">
|
||||
{% if paginate.previous %}
|
||||
<a href="{{ paginate.previous.url }}">Previous</a>
|
||||
{% endif %}
|
||||
|
||||
{% for part in paginate.parts %}
|
||||
{% if part.is_link %}
|
||||
<a href="{{ part.url }}">{{ part.title }}</a>
|
||||
{% else %}
|
||||
<span class="current">{{ part.title }}</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if paginate.next %}
|
||||
<a href="{{ paginate.next.url }}">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endpaginate %}
|
||||
```
|
||||
|
||||
### Cart AJAX
|
||||
|
||||
```javascript
|
||||
// Add to cart
|
||||
fetch('/cart/add.js', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: variantId,
|
||||
quantity: 1
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(item => console.log('Added:', item));
|
||||
|
||||
// Get cart
|
||||
fetch('/cart.js')
|
||||
.then(res => res.json())
|
||||
.then(cart => console.log('Cart:', cart));
|
||||
|
||||
// Update cart
|
||||
fetch('/cart/change.js', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: lineItemKey,
|
||||
quantity: 2
|
||||
})
|
||||
})
|
||||
.then(res => res.json());
|
||||
```
|
||||
|
||||
## Metafields in Themes
|
||||
|
||||
Access custom data:
|
||||
|
||||
```liquid
|
||||
{{ product.metafields.custom.care_instructions }}
|
||||
{{ product.metafields.custom.material.value }}
|
||||
|
||||
{% if product.metafields.custom.featured %}
|
||||
<span class="badge">Featured</span>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
**Performance:**
|
||||
- Optimize images (use appropriate sizes)
|
||||
- Minimize Liquid logic complexity
|
||||
- Use lazy loading for images
|
||||
- Defer non-critical JavaScript
|
||||
|
||||
**Accessibility:**
|
||||
- Use semantic HTML
|
||||
- Include alt text for images
|
||||
- Support keyboard navigation
|
||||
- Ensure sufficient color contrast
|
||||
|
||||
**SEO:**
|
||||
- Use descriptive page titles
|
||||
- Include meta descriptions
|
||||
- Structure content with headings
|
||||
- Implement schema markup
|
||||
|
||||
**Code Quality:**
|
||||
- Follow Shopify theme guidelines
|
||||
- Use consistent naming conventions
|
||||
- Comment complex logic
|
||||
- Keep sections focused and reusable
|
||||
|
||||
## Resources
|
||||
|
||||
- Theme Development: https://shopify.dev/docs/themes
|
||||
- Liquid Reference: https://shopify.dev/docs/api/liquid
|
||||
- Dawn Theme: https://github.com/Shopify/dawn
|
||||
- Theme Check: https://shopify.dev/docs/themes/tools/theme-check
|
||||
49
skills/shopify-development/scripts/.gitignore
vendored
Normal file
49
skills/shopify-development/scripts/.gitignore
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Testing
|
||||
.coverage
|
||||
.pytest_cache/
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
19
skills/shopify-development/scripts/requirements.txt
Normal file
19
skills/shopify-development/scripts/requirements.txt
Normal file
@@ -0,0 +1,19 @@
|
||||
# Shopify Skill Dependencies
|
||||
# Python 3.10+ required
|
||||
|
||||
# No Python package dependencies - uses only standard library
|
||||
|
||||
# Testing dependencies (dev)
|
||||
pytest>=8.0.0
|
||||
pytest-cov>=4.1.0
|
||||
pytest-mock>=3.12.0
|
||||
|
||||
# Note: This script requires the Shopify CLI tool
|
||||
# Install Shopify CLI:
|
||||
# npm install -g @shopify/cli @shopify/theme
|
||||
# or via Homebrew (macOS):
|
||||
# brew tap shopify/shopify
|
||||
# brew install shopify-cli
|
||||
#
|
||||
# Authenticate with:
|
||||
# shopify auth login
|
||||
428
skills/shopify-development/scripts/shopify_graphql.py
Normal file
428
skills/shopify-development/scripts/shopify_graphql.py
Normal file
@@ -0,0 +1,428 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shopify GraphQL Utilities
|
||||
|
||||
Helper functions for common Shopify GraphQL operations.
|
||||
Provides query templates, pagination helpers, and rate limit handling.
|
||||
|
||||
Usage:
|
||||
from shopify_graphql import ShopifyGraphQL
|
||||
|
||||
client = ShopifyGraphQL(shop_domain, access_token)
|
||||
products = client.get_products(first=10)
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
from typing import Dict, List, Optional, Any, Generator
|
||||
from dataclasses import dataclass
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError
|
||||
|
||||
|
||||
# API Configuration
|
||||
API_VERSION = "2026-01"
|
||||
MAX_RETRIES = 3
|
||||
RETRY_DELAY = 1.0 # seconds
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphQLResponse:
|
||||
"""Container for GraphQL response data."""
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
errors: Optional[List[Dict[str, Any]]] = None
|
||||
extensions: Optional[Dict[str, Any]] = None
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.errors is None or len(self.errors) == 0
|
||||
|
||||
@property
|
||||
def query_cost(self) -> Optional[int]:
|
||||
"""Get the actual query cost from extensions."""
|
||||
if self.extensions and 'cost' in self.extensions:
|
||||
return self.extensions['cost'].get('actualQueryCost')
|
||||
return None
|
||||
|
||||
|
||||
class ShopifyGraphQL:
|
||||
"""
|
||||
Shopify GraphQL API client with built-in utilities.
|
||||
|
||||
Features:
|
||||
- Query templates for common operations
|
||||
- Automatic pagination
|
||||
- Rate limit handling with exponential backoff
|
||||
- Response parsing helpers
|
||||
"""
|
||||
|
||||
def __init__(self, shop_domain: str, access_token: str):
|
||||
"""
|
||||
Initialize the GraphQL client.
|
||||
|
||||
Args:
|
||||
shop_domain: Store domain (e.g., 'my-store.myshopify.com')
|
||||
access_token: Admin API access token
|
||||
"""
|
||||
self.shop_domain = shop_domain.replace('https://', '').replace('http://', '')
|
||||
self.access_token = access_token
|
||||
self.base_url = f"https://{self.shop_domain}/admin/api/{API_VERSION}/graphql.json"
|
||||
|
||||
def execute(self, query: str, variables: Optional[Dict] = None) -> GraphQLResponse:
|
||||
"""
|
||||
Execute a GraphQL query/mutation.
|
||||
|
||||
Args:
|
||||
query: GraphQL query string
|
||||
variables: Query variables
|
||||
|
||||
Returns:
|
||||
GraphQLResponse object
|
||||
"""
|
||||
payload = {"query": query}
|
||||
if variables:
|
||||
payload["variables"] = variables
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Shopify-Access-Token": self.access_token
|
||||
}
|
||||
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
request = Request(
|
||||
self.base_url,
|
||||
data=json.dumps(payload).encode('utf-8'),
|
||||
headers=headers,
|
||||
method='POST'
|
||||
)
|
||||
|
||||
with urlopen(request, timeout=30) as response:
|
||||
result = json.loads(response.read().decode('utf-8'))
|
||||
return GraphQLResponse(
|
||||
data=result.get('data'),
|
||||
errors=result.get('errors'),
|
||||
extensions=result.get('extensions')
|
||||
)
|
||||
|
||||
except HTTPError as e:
|
||||
if e.code == 429: # Rate limited
|
||||
delay = RETRY_DELAY * (2 ** attempt)
|
||||
print(f"Rate limited. Retrying in {delay}s...")
|
||||
time.sleep(delay)
|
||||
continue
|
||||
raise
|
||||
except Exception as e:
|
||||
if attempt == MAX_RETRIES - 1:
|
||||
raise
|
||||
time.sleep(RETRY_DELAY)
|
||||
|
||||
return GraphQLResponse(errors=[{"message": "Max retries exceeded"}])
|
||||
|
||||
# ==================== Query Templates ====================
|
||||
|
||||
def get_products(
|
||||
self,
|
||||
first: int = 10,
|
||||
query: Optional[str] = None,
|
||||
after: Optional[str] = None
|
||||
) -> GraphQLResponse:
|
||||
"""
|
||||
Query products with pagination.
|
||||
|
||||
Args:
|
||||
first: Number of products to fetch (max 250)
|
||||
query: Optional search query
|
||||
after: Cursor for pagination
|
||||
"""
|
||||
gql = """
|
||||
query GetProducts($first: Int!, $query: String, $after: String) {
|
||||
products(first: $first, query: $query, after: $after) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
handle
|
||||
status
|
||||
totalInventory
|
||||
variants(first: 5) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
title
|
||||
price
|
||||
inventoryQuantity
|
||||
sku
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
return self.execute(gql, {"first": first, "query": query, "after": after})
|
||||
|
||||
def get_orders(
|
||||
self,
|
||||
first: int = 10,
|
||||
query: Optional[str] = None,
|
||||
after: Optional[str] = None
|
||||
) -> GraphQLResponse:
|
||||
"""
|
||||
Query orders with pagination.
|
||||
|
||||
Args:
|
||||
first: Number of orders to fetch (max 250)
|
||||
query: Optional search query (e.g., "financial_status:paid")
|
||||
after: Cursor for pagination
|
||||
"""
|
||||
gql = """
|
||||
query GetOrders($first: Int!, $query: String, $after: String) {
|
||||
orders(first: $first, query: $query, after: $after) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
createdAt
|
||||
displayFinancialStatus
|
||||
displayFulfillmentStatus
|
||||
totalPriceSet {
|
||||
shopMoney { amount currencyCode }
|
||||
}
|
||||
customer {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
}
|
||||
lineItems(first: 5) {
|
||||
edges {
|
||||
node {
|
||||
title
|
||||
quantity
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cursor
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
return self.execute(gql, {"first": first, "query": query, "after": after})
|
||||
|
||||
def get_customers(
|
||||
self,
|
||||
first: int = 10,
|
||||
query: Optional[str] = None,
|
||||
after: Optional[str] = None
|
||||
) -> GraphQLResponse:
|
||||
"""
|
||||
Query customers with pagination.
|
||||
|
||||
Args:
|
||||
first: Number of customers to fetch (max 250)
|
||||
query: Optional search query
|
||||
after: Cursor for pagination
|
||||
"""
|
||||
gql = """
|
||||
query GetCustomers($first: Int!, $query: String, $after: String) {
|
||||
customers(first: $first, query: $query, after: $after) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
displayName
|
||||
defaultEmailAddress {
|
||||
emailAddress
|
||||
}
|
||||
numberOfOrders
|
||||
amountSpent {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
}
|
||||
cursor
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
return self.execute(gql, {"first": first, "query": query, "after": after})
|
||||
|
||||
def set_metafields(self, metafields: List[Dict]) -> GraphQLResponse:
|
||||
"""
|
||||
Set metafields on resources.
|
||||
|
||||
Args:
|
||||
metafields: List of metafield inputs, each containing:
|
||||
- ownerId: Resource GID
|
||||
- namespace: Metafield namespace
|
||||
- key: Metafield key
|
||||
- value: Metafield value
|
||||
- type: Metafield type
|
||||
"""
|
||||
gql = """
|
||||
mutation SetMetafields($metafields: [MetafieldsSetInput!]!) {
|
||||
metafieldsSet(metafields: $metafields) {
|
||||
metafields {
|
||||
id
|
||||
namespace
|
||||
key
|
||||
value
|
||||
}
|
||||
userErrors {
|
||||
field
|
||||
message
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
return self.execute(gql, {"metafields": metafields})
|
||||
|
||||
# ==================== Pagination Helpers ====================
|
||||
|
||||
def paginate_products(
|
||||
self,
|
||||
batch_size: int = 50,
|
||||
query: Optional[str] = None
|
||||
) -> Generator[Dict, None, None]:
|
||||
"""
|
||||
Generator that yields all products with automatic pagination.
|
||||
|
||||
Args:
|
||||
batch_size: Products per request (max 250)
|
||||
query: Optional search query
|
||||
|
||||
Yields:
|
||||
Product dictionaries
|
||||
"""
|
||||
cursor = None
|
||||
while True:
|
||||
response = self.get_products(first=batch_size, query=query, after=cursor)
|
||||
|
||||
if not response.is_success or not response.data:
|
||||
break
|
||||
|
||||
products = response.data.get('products', {})
|
||||
edges = products.get('edges', [])
|
||||
|
||||
for edge in edges:
|
||||
yield edge['node']
|
||||
|
||||
page_info = products.get('pageInfo', {})
|
||||
if not page_info.get('hasNextPage'):
|
||||
break
|
||||
|
||||
cursor = page_info.get('endCursor')
|
||||
|
||||
def paginate_orders(
|
||||
self,
|
||||
batch_size: int = 50,
|
||||
query: Optional[str] = None
|
||||
) -> Generator[Dict, None, None]:
|
||||
"""
|
||||
Generator that yields all orders with automatic pagination.
|
||||
|
||||
Args:
|
||||
batch_size: Orders per request (max 250)
|
||||
query: Optional search query
|
||||
|
||||
Yields:
|
||||
Order dictionaries
|
||||
"""
|
||||
cursor = None
|
||||
while True:
|
||||
response = self.get_orders(first=batch_size, query=query, after=cursor)
|
||||
|
||||
if not response.is_success or not response.data:
|
||||
break
|
||||
|
||||
orders = response.data.get('orders', {})
|
||||
edges = orders.get('edges', [])
|
||||
|
||||
for edge in edges:
|
||||
yield edge['node']
|
||||
|
||||
page_info = orders.get('pageInfo', {})
|
||||
if not page_info.get('hasNextPage'):
|
||||
break
|
||||
|
||||
cursor = page_info.get('endCursor')
|
||||
|
||||
|
||||
# ==================== Utility Functions ====================
|
||||
|
||||
def extract_id(gid: str) -> str:
|
||||
"""
|
||||
Extract numeric ID from Shopify GID.
|
||||
|
||||
Args:
|
||||
gid: Global ID (e.g., 'gid://shopify/Product/123')
|
||||
|
||||
Returns:
|
||||
Numeric ID string (e.g., '123')
|
||||
"""
|
||||
return gid.split('/')[-1] if gid else ''
|
||||
|
||||
|
||||
def build_gid(resource_type: str, id: str) -> str:
|
||||
"""
|
||||
Build Shopify GID from resource type and ID.
|
||||
|
||||
Args:
|
||||
resource_type: Resource type (e.g., 'Product', 'Order')
|
||||
id: Numeric ID
|
||||
|
||||
Returns:
|
||||
Global ID (e.g., 'gid://shopify/Product/123')
|
||||
"""
|
||||
return f"gid://shopify/{resource_type}/{id}"
|
||||
|
||||
|
||||
# ==================== Example Usage ====================
|
||||
|
||||
def main():
|
||||
"""Example usage of ShopifyGraphQL client."""
|
||||
import os
|
||||
|
||||
# Load from environment
|
||||
shop = os.environ.get('SHOP_DOMAIN', 'your-store.myshopify.com')
|
||||
token = os.environ.get('SHOPIFY_ACCESS_TOKEN', '')
|
||||
|
||||
if not token:
|
||||
print("Set SHOPIFY_ACCESS_TOKEN environment variable")
|
||||
return
|
||||
|
||||
client = ShopifyGraphQL(shop, token)
|
||||
|
||||
# Example: Get first 5 products
|
||||
print("Fetching products...")
|
||||
response = client.get_products(first=5)
|
||||
|
||||
if response.is_success:
|
||||
products = response.data['products']['edges']
|
||||
for edge in products:
|
||||
product = edge['node']
|
||||
print(f" - {product['title']} ({product['status']})")
|
||||
print(f"\nQuery cost: {response.query_cost}")
|
||||
else:
|
||||
print(f"Errors: {response.errors}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
441
skills/shopify-development/scripts/shopify_init.py
Normal file
441
skills/shopify-development/scripts/shopify_init.py
Normal file
@@ -0,0 +1,441 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shopify Project Initialization Script
|
||||
|
||||
Interactive script to scaffold Shopify apps, extensions, or themes.
|
||||
Supports environment variable loading from multiple locations.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, List
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnvConfig:
|
||||
"""Environment configuration container."""
|
||||
shopify_api_key: Optional[str] = None
|
||||
shopify_api_secret: Optional[str] = None
|
||||
shop_domain: Optional[str] = None
|
||||
scopes: Optional[str] = None
|
||||
|
||||
|
||||
class EnvLoader:
|
||||
"""Load environment variables from multiple sources in priority order."""
|
||||
|
||||
@staticmethod
|
||||
def load_env_file(filepath: Path) -> Dict[str, str]:
|
||||
"""
|
||||
Load environment variables from .env file.
|
||||
|
||||
Args:
|
||||
filepath: Path to .env file
|
||||
|
||||
Returns:
|
||||
Dictionary of environment variables
|
||||
"""
|
||||
env_vars = {}
|
||||
if not filepath.exists():
|
||||
return env_vars
|
||||
|
||||
try:
|
||||
with open(filepath, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
env_vars[key.strip()] = value.strip().strip('"').strip("'")
|
||||
except Exception as e:
|
||||
print(f"Warning: Failed to load {filepath}: {e}")
|
||||
|
||||
return env_vars
|
||||
|
||||
@staticmethod
|
||||
def get_env_paths(skill_dir: Path) -> List[Path]:
|
||||
"""
|
||||
Get list of .env file paths in priority order.
|
||||
|
||||
Works with any AI tool directory structure:
|
||||
- .agent/skills/ (universal)
|
||||
- .claude/skills/ (Claude Code)
|
||||
- .gemini/skills/ (Gemini CLI)
|
||||
- .cursor/skills/ (Cursor)
|
||||
|
||||
Priority: process.env > skill/.env > skills/.env > agent_dir/.env
|
||||
|
||||
Args:
|
||||
skill_dir: Path to skill directory
|
||||
|
||||
Returns:
|
||||
List of .env file paths
|
||||
"""
|
||||
paths = []
|
||||
|
||||
# skill/.env
|
||||
skill_env = skill_dir / '.env'
|
||||
if skill_env.exists():
|
||||
paths.append(skill_env)
|
||||
|
||||
# skills/.env
|
||||
skills_env = skill_dir.parent / '.env'
|
||||
if skills_env.exists():
|
||||
paths.append(skills_env)
|
||||
|
||||
# agent_dir/.env (e.g., .agent, .claude, .gemini, .cursor)
|
||||
agent_env = skill_dir.parent.parent / '.env'
|
||||
if agent_env.exists():
|
||||
paths.append(agent_env)
|
||||
|
||||
return paths
|
||||
|
||||
@staticmethod
|
||||
def load_config(skill_dir: Path) -> EnvConfig:
|
||||
"""
|
||||
Load configuration from environment variables.
|
||||
|
||||
Works with any AI tool directory structure.
|
||||
Priority: process.env > skill/.env > skills/.env > agent_dir/.env
|
||||
|
||||
Args:
|
||||
skill_dir: Path to skill directory
|
||||
|
||||
Returns:
|
||||
EnvConfig object
|
||||
"""
|
||||
config = EnvConfig()
|
||||
|
||||
# Load from .env files (reverse priority order)
|
||||
for env_path in reversed(EnvLoader.get_env_paths(skill_dir)):
|
||||
env_vars = EnvLoader.load_env_file(env_path)
|
||||
if 'SHOPIFY_API_KEY' in env_vars:
|
||||
config.shopify_api_key = env_vars['SHOPIFY_API_KEY']
|
||||
if 'SHOPIFY_API_SECRET' in env_vars:
|
||||
config.shopify_api_secret = env_vars['SHOPIFY_API_SECRET']
|
||||
if 'SHOP_DOMAIN' in env_vars:
|
||||
config.shop_domain = env_vars['SHOP_DOMAIN']
|
||||
if 'SCOPES' in env_vars:
|
||||
config.scopes = env_vars['SCOPES']
|
||||
|
||||
# Override with process environment (highest priority)
|
||||
if 'SHOPIFY_API_KEY' in os.environ:
|
||||
config.shopify_api_key = os.environ['SHOPIFY_API_KEY']
|
||||
if 'SHOPIFY_API_SECRET' in os.environ:
|
||||
config.shopify_api_secret = os.environ['SHOPIFY_API_SECRET']
|
||||
if 'SHOP_DOMAIN' in os.environ:
|
||||
config.shop_domain = os.environ['SHOP_DOMAIN']
|
||||
if 'SCOPES' in os.environ:
|
||||
config.scopes = os.environ['SCOPES']
|
||||
|
||||
return config
|
||||
|
||||
|
||||
class ShopifyInitializer:
|
||||
"""Initialize Shopify projects."""
|
||||
|
||||
def __init__(self, config: EnvConfig):
|
||||
"""
|
||||
Initialize ShopifyInitializer.
|
||||
|
||||
Args:
|
||||
config: Environment configuration
|
||||
"""
|
||||
self.config = config
|
||||
|
||||
def prompt(self, message: str, default: Optional[str] = None) -> str:
|
||||
"""
|
||||
Prompt user for input.
|
||||
|
||||
Args:
|
||||
message: Prompt message
|
||||
default: Default value
|
||||
|
||||
Returns:
|
||||
User input or default
|
||||
"""
|
||||
if default:
|
||||
message = f"{message} [{default}]"
|
||||
user_input = input(f"{message}: ").strip()
|
||||
return user_input if user_input else (default or '')
|
||||
|
||||
def select_option(self, message: str, options: List[str]) -> str:
|
||||
"""
|
||||
Prompt user to select from options.
|
||||
|
||||
Args:
|
||||
message: Prompt message
|
||||
options: List of options
|
||||
|
||||
Returns:
|
||||
Selected option
|
||||
"""
|
||||
print(f"\n{message}")
|
||||
for i, option in enumerate(options, 1):
|
||||
print(f"{i}. {option}")
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = int(input("Select option: ").strip())
|
||||
if 1 <= choice <= len(options):
|
||||
return options[choice - 1]
|
||||
print(f"Please select 1-{len(options)}")
|
||||
except (ValueError, KeyboardInterrupt):
|
||||
print("Invalid input")
|
||||
|
||||
def check_cli_installed(self) -> bool:
|
||||
"""
|
||||
Check if Shopify CLI is installed.
|
||||
|
||||
Returns:
|
||||
True if installed, False otherwise
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['shopify', 'version'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.SubprocessError, FileNotFoundError):
|
||||
return False
|
||||
|
||||
def create_app_config(self, project_dir: Path, app_name: str, scopes: str) -> None:
|
||||
"""
|
||||
Create shopify.app.toml configuration file.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory
|
||||
app_name: Application name
|
||||
scopes: Access scopes
|
||||
"""
|
||||
config_content = f"""# Shopify App Configuration
|
||||
name = "{app_name}"
|
||||
client_id = "{self.config.shopify_api_key or 'YOUR_API_KEY'}"
|
||||
application_url = "https://your-app.com"
|
||||
embedded = true
|
||||
|
||||
[build]
|
||||
automatically_update_urls_on_dev = true
|
||||
dev_store_url = "{self.config.shop_domain or 'your-store.myshopify.com'}"
|
||||
|
||||
[access_scopes]
|
||||
scopes = "{scopes}"
|
||||
|
||||
[webhooks]
|
||||
api_version = "2026-01"
|
||||
|
||||
[[webhooks.subscriptions]]
|
||||
topics = ["app/uninstalled"]
|
||||
uri = "/webhooks/app/uninstalled"
|
||||
|
||||
[webhooks.privacy_compliance]
|
||||
customer_data_request_url = "/webhooks/gdpr/data-request"
|
||||
customer_deletion_url = "/webhooks/gdpr/customer-deletion"
|
||||
shop_deletion_url = "/webhooks/gdpr/shop-deletion"
|
||||
"""
|
||||
config_path = project_dir / 'shopify.app.toml'
|
||||
config_path.write_text(config_content)
|
||||
print(f"✓ Created {config_path}")
|
||||
|
||||
def create_extension_config(self, project_dir: Path, extension_name: str, extension_type: str) -> None:
|
||||
"""
|
||||
Create shopify.extension.toml configuration file.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory
|
||||
extension_name: Extension name
|
||||
extension_type: Extension type
|
||||
"""
|
||||
target_map = {
|
||||
'checkout': 'purchase.checkout.block.render',
|
||||
'admin_action': 'admin.product-details.action.render',
|
||||
'admin_block': 'admin.product-details.block.render',
|
||||
'pos': 'pos.home.tile.render',
|
||||
'function': 'function',
|
||||
'customer_account': 'customer-account.order-status.block.render',
|
||||
'theme_app': 'theme-app-extension'
|
||||
}
|
||||
|
||||
config_content = f"""name = "{extension_name}"
|
||||
type = "ui_extension"
|
||||
handle = "{extension_name.lower().replace(' ', '-')}"
|
||||
|
||||
[extension_points]
|
||||
api_version = "2026-01"
|
||||
|
||||
[[extension_points.targets]]
|
||||
target = "{target_map.get(extension_type, 'purchase.checkout.block.render')}"
|
||||
|
||||
[capabilities]
|
||||
network_access = true
|
||||
api_access = true
|
||||
"""
|
||||
config_path = project_dir / 'shopify.extension.toml'
|
||||
config_path.write_text(config_content)
|
||||
print(f"✓ Created {config_path}")
|
||||
|
||||
def create_readme(self, project_dir: Path, project_type: str, project_name: str) -> None:
|
||||
"""
|
||||
Create README.md file.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory
|
||||
project_type: Project type (app/extension/theme)
|
||||
project_name: Project name
|
||||
"""
|
||||
content = f"""# {project_name}
|
||||
|
||||
Shopify {project_type.capitalize()} project.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development
|
||||
shopify {project_type} dev
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
# Deploy to Shopify
|
||||
shopify {project_type} deploy
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Shopify Documentation](https://shopify.dev/docs)
|
||||
- [Shopify CLI](https://shopify.dev/docs/api/shopify-cli)
|
||||
"""
|
||||
readme_path = project_dir / 'README.md'
|
||||
readme_path.write_text(content)
|
||||
print(f"✓ Created {readme_path}")
|
||||
|
||||
def init_app(self) -> None:
|
||||
"""Initialize Shopify app project."""
|
||||
print("\n=== Shopify App Initialization ===\n")
|
||||
|
||||
app_name = self.prompt("App name", "my-shopify-app")
|
||||
scopes = self.prompt("Access scopes", self.config.scopes or "read_products,write_products")
|
||||
|
||||
project_dir = Path.cwd() / app_name
|
||||
project_dir.mkdir(exist_ok=True)
|
||||
|
||||
print(f"\nCreating app in {project_dir}...")
|
||||
|
||||
self.create_app_config(project_dir, app_name, scopes)
|
||||
self.create_readme(project_dir, "app", app_name)
|
||||
|
||||
# Create basic package.json
|
||||
package_json = {
|
||||
"name": app_name.lower().replace(' ', '-'),
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "shopify app dev",
|
||||
"deploy": "shopify app deploy"
|
||||
}
|
||||
}
|
||||
(project_dir / 'package.json').write_text(json.dumps(package_json, indent=2))
|
||||
print(f"✓ Created package.json")
|
||||
|
||||
print(f"\n✓ App '{app_name}' initialized successfully!")
|
||||
print(f"\nNext steps:")
|
||||
print(f" cd {app_name}")
|
||||
print(f" npm install")
|
||||
print(f" shopify app dev")
|
||||
|
||||
def init_extension(self) -> None:
|
||||
"""Initialize Shopify extension project."""
|
||||
print("\n=== Shopify Extension Initialization ===\n")
|
||||
|
||||
extension_types = [
|
||||
'checkout',
|
||||
'admin_action',
|
||||
'admin_block',
|
||||
'pos',
|
||||
'function',
|
||||
'customer_account',
|
||||
'theme_app'
|
||||
]
|
||||
extension_type = self.select_option("Select extension type", extension_types)
|
||||
|
||||
extension_name = self.prompt("Extension name", "my-extension")
|
||||
|
||||
project_dir = Path.cwd() / extension_name
|
||||
project_dir.mkdir(exist_ok=True)
|
||||
|
||||
print(f"\nCreating extension in {project_dir}...")
|
||||
|
||||
self.create_extension_config(project_dir, extension_name, extension_type)
|
||||
self.create_readme(project_dir, "extension", extension_name)
|
||||
|
||||
print(f"\n✓ Extension '{extension_name}' initialized successfully!")
|
||||
print(f"\nNext steps:")
|
||||
print(f" cd {extension_name}")
|
||||
print(f" shopify app dev")
|
||||
|
||||
def init_theme(self) -> None:
|
||||
"""Initialize Shopify theme project."""
|
||||
print("\n=== Shopify Theme Initialization ===\n")
|
||||
|
||||
theme_name = self.prompt("Theme name", "my-theme")
|
||||
|
||||
print(f"\nInitializing theme '{theme_name}'...")
|
||||
print("\nRecommended: Use 'shopify theme init' for full theme scaffolding")
|
||||
print(f"\nRun: shopify theme init {theme_name}")
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run interactive initialization."""
|
||||
print("=" * 60)
|
||||
print("Shopify Project Initializer")
|
||||
print("=" * 60)
|
||||
|
||||
# Check CLI
|
||||
if not self.check_cli_installed():
|
||||
print("\n⚠ Shopify CLI not found!")
|
||||
print("Install: npm install -g @shopify/cli@latest")
|
||||
sys.exit(1)
|
||||
|
||||
# Select project type
|
||||
project_types = ['app', 'extension', 'theme']
|
||||
project_type = self.select_option("Select project type", project_types)
|
||||
|
||||
# Initialize based on type
|
||||
if project_type == 'app':
|
||||
self.init_app()
|
||||
elif project_type == 'extension':
|
||||
self.init_extension()
|
||||
elif project_type == 'theme':
|
||||
self.init_theme()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point."""
|
||||
try:
|
||||
# Get skill directory
|
||||
script_dir = Path(__file__).parent
|
||||
skill_dir = script_dir.parent
|
||||
|
||||
# Load configuration
|
||||
config = EnvLoader.load_config(skill_dir)
|
||||
|
||||
# Initialize project
|
||||
initializer = ShopifyInitializer(config)
|
||||
initializer.run()
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nAborted.")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
379
skills/shopify-development/scripts/tests/test_shopify_init.py
Normal file
379
skills/shopify-development/scripts/tests/test_shopify_init.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
Tests for shopify_init.py
|
||||
|
||||
Run with: pytest test_shopify_init.py -v --cov=shopify_init --cov-report=term-missing
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import pytest
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch, mock_open, MagicMock
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from shopify_init import EnvLoader, EnvConfig, ShopifyInitializer
|
||||
|
||||
|
||||
class TestEnvLoader:
|
||||
"""Test EnvLoader class."""
|
||||
|
||||
def test_load_env_file_success(self, tmp_path):
|
||||
"""Test loading valid .env file."""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("""
|
||||
SHOPIFY_API_KEY=test_key
|
||||
SHOPIFY_API_SECRET=test_secret
|
||||
SHOP_DOMAIN=test.myshopify.com
|
||||
# Comment line
|
||||
SCOPES=read_products,write_products
|
||||
""")
|
||||
|
||||
result = EnvLoader.load_env_file(env_file)
|
||||
|
||||
assert result['SHOPIFY_API_KEY'] == 'test_key'
|
||||
assert result['SHOPIFY_API_SECRET'] == 'test_secret'
|
||||
assert result['SHOP_DOMAIN'] == 'test.myshopify.com'
|
||||
assert result['SCOPES'] == 'read_products,write_products'
|
||||
|
||||
def test_load_env_file_with_quotes(self, tmp_path):
|
||||
"""Test loading .env file with quoted values."""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("""
|
||||
SHOPIFY_API_KEY="test_key"
|
||||
SHOPIFY_API_SECRET='test_secret'
|
||||
""")
|
||||
|
||||
result = EnvLoader.load_env_file(env_file)
|
||||
|
||||
assert result['SHOPIFY_API_KEY'] == 'test_key'
|
||||
assert result['SHOPIFY_API_SECRET'] == 'test_secret'
|
||||
|
||||
def test_load_env_file_nonexistent(self, tmp_path):
|
||||
"""Test loading non-existent .env file."""
|
||||
result = EnvLoader.load_env_file(tmp_path / "nonexistent.env")
|
||||
assert result == {}
|
||||
|
||||
def test_load_env_file_invalid_format(self, tmp_path):
|
||||
"""Test loading .env file with invalid lines."""
|
||||
env_file = tmp_path / ".env"
|
||||
env_file.write_text("""
|
||||
VALID_KEY=value
|
||||
INVALID_LINE_NO_EQUALS
|
||||
ANOTHER_VALID=test
|
||||
""")
|
||||
|
||||
result = EnvLoader.load_env_file(env_file)
|
||||
|
||||
assert result['VALID_KEY'] == 'value'
|
||||
assert result['ANOTHER_VALID'] == 'test'
|
||||
assert 'INVALID_LINE_NO_EQUALS' not in result
|
||||
|
||||
def test_get_env_paths(self, tmp_path):
|
||||
"""Test getting .env file paths from universal directory structure."""
|
||||
# Create directory structure (works with .agent, .claude, .gemini, .cursor)
|
||||
agent_dir = tmp_path / ".agent"
|
||||
skills_dir = agent_dir / "skills"
|
||||
skill_dir = skills_dir / "shopify"
|
||||
|
||||
skill_dir.mkdir(parents=True)
|
||||
|
||||
# Create .env files at each level
|
||||
(skill_dir / ".env").write_text("SKILL=1")
|
||||
(skills_dir / ".env").write_text("SKILLS=1")
|
||||
(agent_dir / ".env").write_text("AGENT=1")
|
||||
|
||||
paths = EnvLoader.get_env_paths(skill_dir)
|
||||
|
||||
assert len(paths) == 3
|
||||
assert skill_dir / ".env" in paths
|
||||
assert skills_dir / ".env" in paths
|
||||
assert agent_dir / ".env" in paths
|
||||
|
||||
def test_load_config_priority(self, tmp_path, monkeypatch):
|
||||
"""Test configuration loading priority across different AI tool directories."""
|
||||
skill_dir = tmp_path / "skill"
|
||||
skills_dir = tmp_path
|
||||
agent_dir = tmp_path.parent # Could be .agent, .claude, .gemini, .cursor
|
||||
|
||||
skill_dir.mkdir(parents=True)
|
||||
|
||||
(skill_dir / ".env").write_text("SHOPIFY_API_KEY=skill_key")
|
||||
(skills_dir / ".env").write_text("SHOPIFY_API_KEY=skills_key\nSHOP_DOMAIN=skills.myshopify.com")
|
||||
|
||||
monkeypatch.setenv("SHOPIFY_API_KEY", "process_key")
|
||||
|
||||
config = EnvLoader.load_config(skill_dir)
|
||||
|
||||
assert config.shopify_api_key == "process_key"
|
||||
# Shop domain from skills/.env
|
||||
assert config.shop_domain == "skills.myshopify.com"
|
||||
|
||||
def test_load_config_no_files(self, tmp_path):
|
||||
"""Test configuration loading with no .env files."""
|
||||
config = EnvLoader.load_config(tmp_path)
|
||||
|
||||
assert config.shopify_api_key is None
|
||||
assert config.shopify_api_secret is None
|
||||
assert config.shop_domain is None
|
||||
assert config.scopes is None
|
||||
|
||||
|
||||
class TestShopifyInitializer:
|
||||
"""Test ShopifyInitializer class."""
|
||||
|
||||
@pytest.fixture
|
||||
def config(self):
|
||||
"""Create test config."""
|
||||
return EnvConfig(
|
||||
shopify_api_key="test_key",
|
||||
shopify_api_secret="test_secret",
|
||||
shop_domain="test.myshopify.com",
|
||||
scopes="read_products,write_products"
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def initializer(self, config):
|
||||
"""Create initializer instance."""
|
||||
return ShopifyInitializer(config)
|
||||
|
||||
def test_prompt_with_default(self, initializer):
|
||||
"""Test prompt with default value."""
|
||||
with patch('builtins.input', return_value=''):
|
||||
result = initializer.prompt("Test", "default_value")
|
||||
assert result == "default_value"
|
||||
|
||||
def test_prompt_with_input(self, initializer):
|
||||
"""Test prompt with user input."""
|
||||
with patch('builtins.input', return_value='user_input'):
|
||||
result = initializer.prompt("Test", "default_value")
|
||||
assert result == "user_input"
|
||||
|
||||
def test_select_option_valid(self, initializer):
|
||||
"""Test select option with valid choice."""
|
||||
options = ['app', 'extension', 'theme']
|
||||
with patch('builtins.input', return_value='2'):
|
||||
result = initializer.select_option("Choose", options)
|
||||
assert result == 'extension'
|
||||
|
||||
def test_select_option_invalid_then_valid(self, initializer):
|
||||
"""Test select option with invalid then valid choice."""
|
||||
options = ['app', 'extension']
|
||||
with patch('builtins.input', side_effect=['5', 'invalid', '1']):
|
||||
result = initializer.select_option("Choose", options)
|
||||
assert result == 'app'
|
||||
|
||||
def test_check_cli_installed_success(self, initializer):
|
||||
"""Test CLI installed check - success."""
|
||||
mock_result = Mock()
|
||||
mock_result.returncode = 0
|
||||
|
||||
with patch('subprocess.run', return_value=mock_result):
|
||||
assert initializer.check_cli_installed() is True
|
||||
|
||||
def test_check_cli_installed_failure(self, initializer):
|
||||
"""Test CLI installed check - failure."""
|
||||
with patch('subprocess.run', side_effect=FileNotFoundError):
|
||||
assert initializer.check_cli_installed() is False
|
||||
|
||||
def test_create_app_config(self, initializer, tmp_path):
|
||||
"""Test creating app configuration file."""
|
||||
initializer.create_app_config(tmp_path, "test-app", "read_products")
|
||||
|
||||
config_file = tmp_path / "shopify.app.toml"
|
||||
assert config_file.exists()
|
||||
|
||||
content = config_file.read_text()
|
||||
assert 'name = "test-app"' in content
|
||||
assert 'scopes = "read_products"' in content
|
||||
assert 'client_id = "test_key"' in content
|
||||
|
||||
def test_create_extension_config(self, initializer, tmp_path):
|
||||
"""Test creating extension configuration file."""
|
||||
initializer.create_extension_config(tmp_path, "test-ext", "checkout")
|
||||
|
||||
config_file = tmp_path / "shopify.extension.toml"
|
||||
assert config_file.exists()
|
||||
|
||||
content = config_file.read_text()
|
||||
assert 'name = "test-ext"' in content
|
||||
assert 'purchase.checkout.block.render' in content
|
||||
|
||||
def test_create_extension_config_admin_action(self, initializer, tmp_path):
|
||||
"""Test creating admin action extension config."""
|
||||
initializer.create_extension_config(tmp_path, "admin-ext", "admin_action")
|
||||
|
||||
config_file = tmp_path / "shopify.extension.toml"
|
||||
content = config_file.read_text()
|
||||
assert 'admin.product-details.action.render' in content
|
||||
|
||||
def test_create_readme(self, initializer, tmp_path):
|
||||
"""Test creating README file."""
|
||||
initializer.create_readme(tmp_path, "app", "Test App")
|
||||
|
||||
readme_file = tmp_path / "README.md"
|
||||
assert readme_file.exists()
|
||||
|
||||
content = readme_file.read_text()
|
||||
assert '# Test App' in content
|
||||
assert 'shopify app dev' in content
|
||||
|
||||
@patch('builtins.input')
|
||||
@patch('builtins.print')
|
||||
def test_init_app(self, mock_print, mock_input, initializer, tmp_path, monkeypatch):
|
||||
"""Test app initialization."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
# Mock user inputs
|
||||
mock_input.side_effect = ['my-app', 'read_products,write_products']
|
||||
|
||||
initializer.init_app()
|
||||
|
||||
# Check directory created
|
||||
app_dir = tmp_path / "my-app"
|
||||
assert app_dir.exists()
|
||||
|
||||
# Check files created
|
||||
assert (app_dir / "shopify.app.toml").exists()
|
||||
assert (app_dir / "README.md").exists()
|
||||
assert (app_dir / "package.json").exists()
|
||||
|
||||
# Check package.json content
|
||||
package_json = json.loads((app_dir / "package.json").read_text())
|
||||
assert package_json['name'] == 'my-app'
|
||||
assert 'dev' in package_json['scripts']
|
||||
|
||||
@patch('builtins.input')
|
||||
@patch('builtins.print')
|
||||
def test_init_extension(self, mock_print, mock_input, initializer, tmp_path, monkeypatch):
|
||||
"""Test extension initialization."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
# Mock user inputs: type selection (1 = checkout), name
|
||||
mock_input.side_effect = ['1', 'my-extension']
|
||||
|
||||
initializer.init_extension()
|
||||
|
||||
# Check directory and files created
|
||||
ext_dir = tmp_path / "my-extension"
|
||||
assert ext_dir.exists()
|
||||
assert (ext_dir / "shopify.extension.toml").exists()
|
||||
assert (ext_dir / "README.md").exists()
|
||||
|
||||
@patch('builtins.input')
|
||||
@patch('builtins.print')
|
||||
def test_init_theme(self, mock_print, mock_input, initializer):
|
||||
"""Test theme initialization."""
|
||||
mock_input.return_value = 'my-theme'
|
||||
|
||||
initializer.init_theme()
|
||||
|
||||
assert mock_print.called
|
||||
|
||||
@patch('builtins.print')
|
||||
def test_run_no_cli(self, mock_print, initializer):
|
||||
"""Test run when CLI not installed."""
|
||||
with patch.object(initializer, 'check_cli_installed', return_value=False):
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
initializer.run()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
@patch.object(ShopifyInitializer, 'check_cli_installed', return_value=True)
|
||||
@patch.object(ShopifyInitializer, 'init_app')
|
||||
@patch('builtins.input')
|
||||
@patch('builtins.print')
|
||||
def test_run_app_selected(self, mock_print, mock_input, mock_init_app, mock_cli_check, initializer):
|
||||
"""Test run with app selection."""
|
||||
mock_input.return_value = '1' # Select app
|
||||
|
||||
initializer.run()
|
||||
|
||||
mock_init_app.assert_called_once()
|
||||
|
||||
@patch.object(ShopifyInitializer, 'check_cli_installed', return_value=True)
|
||||
@patch.object(ShopifyInitializer, 'init_extension')
|
||||
@patch('builtins.input')
|
||||
@patch('builtins.print')
|
||||
def test_run_extension_selected(self, mock_print, mock_input, mock_init_ext, mock_cli_check, initializer):
|
||||
"""Test run with extension selection."""
|
||||
mock_input.return_value = '2' # Select extension
|
||||
|
||||
initializer.run()
|
||||
|
||||
mock_init_ext.assert_called_once()
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Test main function."""
|
||||
|
||||
@patch('shopify_init.ShopifyInitializer')
|
||||
@patch('shopify_init.EnvLoader')
|
||||
def test_main_success(self, mock_loader, mock_initializer):
|
||||
"""Test main function success path."""
|
||||
from shopify_init import main
|
||||
|
||||
mock_config = Mock()
|
||||
mock_loader.load_config.return_value = mock_config
|
||||
|
||||
mock_init_instance = Mock()
|
||||
mock_initializer.return_value = mock_init_instance
|
||||
|
||||
with patch('builtins.print'):
|
||||
main()
|
||||
|
||||
mock_init_instance.run.assert_called_once()
|
||||
|
||||
@patch('shopify_init.ShopifyInitializer')
|
||||
@patch('sys.exit')
|
||||
def test_main_keyboard_interrupt(self, mock_exit, mock_initializer):
|
||||
"""Test main function with keyboard interrupt."""
|
||||
from shopify_init import main
|
||||
|
||||
mock_initializer.return_value.run.side_effect = KeyboardInterrupt
|
||||
|
||||
with patch('builtins.print'):
|
||||
main()
|
||||
|
||||
mock_exit.assert_called_with(0)
|
||||
|
||||
@patch('shopify_init.ShopifyInitializer')
|
||||
@patch('sys.exit')
|
||||
def test_main_exception(self, mock_exit, mock_initializer):
|
||||
"""Test main function with exception."""
|
||||
from shopify_init import main
|
||||
|
||||
mock_initializer.return_value.run.side_effect = Exception("Test error")
|
||||
|
||||
with patch('builtins.print'):
|
||||
main()
|
||||
|
||||
mock_exit.assert_called_with(1)
|
||||
|
||||
|
||||
class TestEnvConfig:
|
||||
"""Test EnvConfig dataclass."""
|
||||
|
||||
def test_env_config_defaults(self):
|
||||
"""Test EnvConfig default values."""
|
||||
config = EnvConfig()
|
||||
|
||||
assert config.shopify_api_key is None
|
||||
assert config.shopify_api_secret is None
|
||||
assert config.shop_domain is None
|
||||
assert config.scopes is None
|
||||
|
||||
def test_env_config_with_values(self):
|
||||
"""Test EnvConfig with values."""
|
||||
config = EnvConfig(
|
||||
shopify_api_key="key",
|
||||
shopify_api_secret="secret",
|
||||
shop_domain="test.myshopify.com",
|
||||
scopes="read_products"
|
||||
)
|
||||
|
||||
assert config.shopify_api_key == "key"
|
||||
assert config.shopify_api_secret == "secret"
|
||||
assert config.shop_domain == "test.myshopify.com"
|
||||
assert config.scopes == "read_products"
|
||||
Reference in New Issue
Block a user