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).
|
- **[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.
|
- **[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.
|
- **[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
|
### 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,
|
claude-code, gemini-cli, codex-cli, antigravity, cursor, github-copilot, opencode,
|
||||||
agentic-skills, ai-coding, llm-tools, ai-agents, autonomous-coding, mcp,
|
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
|
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
|
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