diff --git a/.github/workflows/ci-staging.yml b/.github/workflows/ci-staging.yml new file mode 100644 index 0000000..021502d --- /dev/null +++ b/.github/workflows/ci-staging.yml @@ -0,0 +1,100 @@ +name: CI / PR Preview + +on: + pull_request: + branches: + - staging + +jobs: + build-and-preview: + runs-on: ubuntu-latest + env: + SHOPIFY_STORE: ${{ secrets.SHOPIFY_STORE }} + SHOPIFY_ADMIN_ACCESS_TOKEN: ${{ secrets.SHOPIFY_ADMIN_ACCESS_TOKEN }} + SHOPIFY_API_VERSION: ${{ secrets.SHOPIFY_API_VERSION }} + PRODUCT_HANDLE: ${{ secrets.PRODUCT_HANDLE }} + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install dependencies + run: npm ci + + - name: Validate Shopify secrets + id: validate + run: | + missing="" + for var in SHOPIFY_STORE SHOPIFY_ADMIN_ACCESS_TOKEN SHOPIFY_API_VERSION; do + if [ -z "${!var}" ]; then + missing="$missing\n- $var" + fi + done + if [ -n "$missing" ]; then + echo "missing_secrets<> $GITHUB_OUTPUT + echo "$missing" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + fi + shell: bash + + - name: Comment missing secrets on PR + if: steps.validate.outputs.missing_secrets + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + body="🚨 Missing required Shopify secrets in CI workflow:$\n${{ steps.validate.outputs.missing_secrets }}$\nCreate these secrets under Settings → Secrets → Actions." + gh pr comment ${{ github.event.pull_request.number }} --body "$body" + exit 1 + + - name: Create unpublished theme and upload assets + if: ${{ !steps.validate.outputs.missing_secrets }} + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + STORE: ${{ env.SHOPIFY_STORE }} + TOKEN: ${{ env.SHOPIFY_ADMIN_ACCESS_TOKEN }} + API_VERSION: ${{ env.SHOPIFY_API_VERSION }} + run: | + set -e + THEME_NAME="pr-${PR_NUMBER}" + CREATE_RES=$(curl -s -X POST "https://${STORE}.myshopify.com/admin/api/${API_VERSION}/themes.json" \ + -H "X-Shopify-Access-Token: ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"theme\":{\"name\":\"${THEME_NAME}\",\"role\":\"unpublished\"}}") + THEME_ID=$(echo "$CREATE_RES" | jq -r '.theme.id') + if [ -z "$THEME_ID" ] || [ "$THEME_ID" = "null" ]; then + echo "Failed to create theme: $CREATE_RES" + exit 1 + fi + echo "Created theme $THEME_ID" + echo "THEME_ID=$THEME_ID" >> $GITHUB_ENV + # Upload all files to the theme + for file in $(git ls-files); do + key="$file" + # encode file contents in base64 (no line breaks) + base64content=$(base64 -w 0 "$file") + curl -s -X PUT "https://${STORE}.myshopify.com/admin/api/${API_VERSION}/themes/${THEME_ID}/assets.json" \ + -H "X-Shopify-Access-Token: ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"asset\":{\"key\":\"${key}\",\"attachment\":\"${base64content}\"}}" > /dev/null + done + echo "PREVIEW_BASE_URL=https://${STORE}.myshopify.com/?preview_theme_id=${THEME_ID}" >> $GITHUB_ENV + shell: bash + + - name: Run visual tests + if: ${{ !steps.validate.outputs.missing_secrets }} + env: + PREVIEW_BASE_URL: ${{ env.PREVIEW_BASE_URL }} + run: | + npm run test:visual + + - name: Post preview comment + if: ${{ !steps.validate.outputs.missing_secrets }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + body="🔍 **Preview your changes**: ${{ env.PREVIEW_BASE_URL }}" + gh pr comment ${{ github.event.pull_request.number }} --body "$body" \ No newline at end of file diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 0000000..a0afe4f --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,70 @@ +name: Deploy to Staging Theme + +on: + push: + branches: + - staging + +jobs: + deploy: + runs-on: ubuntu-latest + env: + SHOPIFY_STORE: ${{ secrets.SHOPIFY_STORE }} + SHOPIFY_ADMIN_ACCESS_TOKEN: ${{ secrets.SHOPIFY_ADMIN_ACCESS_TOKEN }} + SHOPIFY_API_VERSION: ${{ secrets.SHOPIFY_API_VERSION }} + SHOPIFY_STAGING_THEME_ID: ${{ secrets.SHOPIFY_STAGING_THEME_ID }} + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install dependencies + run: npm ci + + - name: Build (optional) + run: npm run build --if-present + + - name: Validate Shopify secrets + id: validate + run: | + missing="" + for var in SHOPIFY_STORE SHOPIFY_ADMIN_ACCESS_TOKEN SHOPIFY_API_VERSION SHOPIFY_STAGING_THEME_ID; do + if [ -z "${!var}" ]; then + missing="$missing\n- $var" + fi + done + if [ -n "$missing" ]; then + echo "missing_secrets<> $GITHUB_OUTPUT + echo "$missing" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + fi + shell: bash + + - name: Exit if secrets missing + if: steps.validate.outputs.missing_secrets + run: | + echo "::error::Missing Shopify secrets:${{ steps.validate.outputs.missing_secrets }}" + exit 1 + + - name: Upload assets to staging theme + if: ${{ !steps.validate.outputs.missing_secrets }} + env: + STORE: ${{ env.SHOPIFY_STORE }} + TOKEN: ${{ env.SHOPIFY_ADMIN_ACCESS_TOKEN }} + API_VERSION: ${{ env.SHOPIFY_API_VERSION }} + THEME_ID: ${{ env.SHOPIFY_STAGING_THEME_ID }} + run: | + set -e + for file in $(git ls-files); do + key="$file" + base64content=$(base64 -w 0 "$file") + curl -s -X PUT "https://${STORE}.myshopify.com/admin/api/${API_VERSION}/themes/${THEME_ID}/assets.json" \ + -H "X-Shopify-Access-Token: ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"asset\":{\"key\":\"${key}\",\"attachment\":\"${base64content}\"}}" > /dev/null + done + echo "Deployed to staging theme ${THEME_ID}" \ No newline at end of file diff --git a/.github/workflows/pr-close.yml b/.github/workflows/pr-close.yml new file mode 100644 index 0000000..9ed49b3 --- /dev/null +++ b/.github/workflows/pr-close.yml @@ -0,0 +1,51 @@ +name: Cleanup PR Preview Theme + +on: + pull_request: + types: [closed] + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Validate secrets + id: validate + env: + SHOPIFY_STORE: ${{ secrets.SHOPIFY_STORE }} + SHOPIFY_ADMIN_ACCESS_TOKEN: ${{ secrets.SHOPIFY_ADMIN_ACCESS_TOKEN }} + SHOPIFY_API_VERSION: ${{ secrets.SHOPIFY_API_VERSION }} + run: | + missing="" + for var in SHOPIFY_STORE SHOPIFY_ADMIN_ACCESS_TOKEN SHOPIFY_API_VERSION; do + if [ -z "${!var}" ]; then + missing="$missing\n- $var" + fi + done + if [ -n "$missing" ]; then + echo "missing_secrets<> $GITHUB_OUTPUT + echo "$missing" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + fi + - name: Exit early if secrets missing + if: steps.validate.outputs.missing_secrets + run: echo "Skipping theme deletion due to missing Shopify secrets." + - name: Delete preview theme + if: ${{ !steps.validate.outputs.missing_secrets }} + env: + STORE: ${{ secrets.SHOPIFY_STORE }} + TOKEN: ${{ secrets.SHOPIFY_ADMIN_ACCESS_TOKEN }} + API_VERSION: ${{ secrets.SHOPIFY_API_VERSION }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -e + # fetch all themes and filter for the PR theme name + themes_json=$(curl -s -X GET "https://${STORE}.myshopify.com/admin/api/${API_VERSION}/themes.json" \ + -H "X-Shopify-Access-Token: ${TOKEN}") + theme_id=$(echo "$themes_json" | jq -r ".themes[] | select(.name==\"pr-${PR_NUMBER}\") | .id") + if [ -n "$theme_id" ] && [ "$theme_id" != "null" ]; then + curl -s -X DELETE "https://${STORE}.myshopify.com/admin/api/${API_VERSION}/themes/${theme_id}.json" \ + -H "X-Shopify-Access-Token: ${TOKEN}" + echo "Deleted preview theme ${theme_id}" + else + echo "No preview theme to delete for PR ${PR_NUMBER}." + fi \ No newline at end of file diff --git a/.github/workflows/setup-metaobjects.yml b/.github/workflows/setup-metaobjects.yml new file mode 100644 index 0000000..375f165 --- /dev/null +++ b/.github/workflows/setup-metaobjects.yml @@ -0,0 +1,26 @@ +name: Setup Metaobjects + +on: + workflow_dispatch: + +jobs: + metaobjects: + runs-on: ubuntu-latest + env: + SHOPIFY_STORE: ${{ secrets.SHOPIFY_STORE }} + SHOPIFY_ADMIN_ACCESS_TOKEN: ${{ secrets.SHOPIFY_ADMIN_ACCESS_TOKEN }} + SHOPIFY_API_VERSION: ${{ secrets.SHOPIFY_API_VERSION }} + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install dependencies + run: npm ci + + - name: Ensure metaobjects and metafields + run: npm run shopify:defs \ No newline at end of file diff --git a/assets/ui.css b/assets/ui.css new file mode 100644 index 0000000..e88ef47 --- /dev/null +++ b/assets/ui.css @@ -0,0 +1,47 @@ +/* + * QFlex Utility Styles + * + * This stylesheet contains reusable utility classes for the QFlex theme. All + * bespoke styling previously defined inline has been extracted here to + * improve maintainability and enable composition. Feel free to extend + * these rules or incorporate them into a larger Tailwind-esque framework. + */ + +.qflex-hero .eyebrow, +.qflex-pdp .eyebrow { + font-size: var(--font-size-small, 0.875rem); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.25rem; +} + +.qflex-hero .rating, +.qflex-pdp .rating { + font-weight: 600; + margin-bottom: 0.5rem; +} + +.qflex-hero .review-count, +.qflex-pdp .review-count { + margin-left: 0.25rem; + opacity: 0.7; +} + +.qflex-hero .usp-pills, +.qflex-pdp .usp-pills { + list-style: none; + padding: 0; + margin: 0; + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.qflex-hero .usp-pills li, +.qflex-pdp .usp-pills li { + background-color: rgba(var(--color-background-contrast, 0,0,0), 0.08); + padding: 0.25rem 0.5rem; + border-radius: 999px; + font-size: 0.75rem; + line-height: 1; +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a48d036 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,133 @@ +{ + "name": "qflex-theme", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "qflex-theme", + "version": "1.0.0", + "dependencies": { + "cross-fetch": "^3.1.5" + }, + "devDependencies": { + "@playwright/test": "^1.40.0", + "playwright": "^1.40.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.54.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.54.2.tgz", + "integrity": "sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.54.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/playwright": { + "version": "1.54.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.54.2.tgz", + "integrity": "sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.54.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.54.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.54.2.tgz", + "integrity": "sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ba5f680 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "qflex-theme", + "version": "1.0.0", + "private": true, + "description": "QFlex Shopify theme with custom metaobjects and CI/CD flows", + "scripts": { + "shopify:defs": "node scripts/create_meta_definitions.mjs", + "test:visual": "playwright test" + }, + "dependencies": { + "cross-fetch": "^3.1.5" + }, + "devDependencies": { + "@playwright/test": "^1.40.0", + "playwright": "^1.40.0" + } +} \ No newline at end of file diff --git a/readme.md b/readme.md index 01513b2..4e90849 100644 --- a/readme.md +++ b/readme.md @@ -1,2 +1,93 @@ # QFlex Theme -Initial commit + +This repository hosts the QFlex Shopify theme. The project is wired for a +staging‑only workflow with automated preview environments and visual +regression testing. Pull requests target the `staging` branch and are +validated against an unpublished preview theme. Merging into `staging` +publishes the code to a designated staging theme; the live theme is never +modified from CI. + +## Local development + +Clone the repository, install dependencies and run a local Shopify theme +server as you normally would. The theme depends on Node.js (version 18 or +newer) and uses Playwright for visual tests. + +```bash +npm ci +``` + +### Metaobject and metafield definitions + +QFlex relies on a handful of custom metaobject definitions and product +metafields within the `qf` namespace. These definitions power dynamic +sources such as the hero eyebrow, ratings and unique selling proposition +pills. To provision them locally you can run: + +```bash +SHOPIFY_STORE= +SHOPIFY_ADMIN_ACCESS_TOKEN= +SHOPIFY_API_VERSION= +npm run shopify:defs +``` + +This executes [`scripts/create_meta_definitions.mjs`](scripts/create_meta_definitions.mjs). It +will create (or update) three metaobject definitions—`badge`, `testimonial` +and `press_logo`—plus the following product metafields: + +| Namespace | Key | Type | Purpose | +|-----------|--------------|------------------------------|------------------------------------------| +| `qf` | hero_eyebrow | single_line_text_field | Eyebrow text above the product title | +| `qf` | review_count | number_integer | Total number of reviews | +| `qf` | avg_rating | number_decimal | Average customer rating | +| `qf` | usp_pills | list.single_line_text_field | Unique selling proposition pill strings | + +You can also trigger the **Setup Metaobjects** workflow manually from the +GitHub Actions tab. It runs the same script in CI with the appropriate +secrets. + +### Pull request previews + +When you open a pull request against `staging` the CI workflow defined in +`.github/workflows/ci-staging.yml` performs the following steps: + +1. **Secret validation** – If any of the required Shopify secrets + (`SHOPIFY_STORE`, `SHOPIFY_ADMIN_ACCESS_TOKEN`, `SHOPIFY_API_VERSION`) are + missing the workflow posts a comment to your pull request with a + checklist and aborts. +2. **Theme creation** – The workflow creates an unpublished theme named + `pr-` via the Shopify Admin API. +3. **Asset upload** – All files in the repository are uploaded to the + unpublished theme. The preview URL has the form + `https://.myshopify.com/?preview_theme_id=`. +4. **Visual tests** – Playwright runs visual regression tests against the + preview URL. Failures will fail the build. +5. **Preview comment** – On success the workflow comments on the pull + request with a link to the preview. You can iterate on your branch + until the tests pass. + +Closing a pull request (regardless of merge status) triggers +`pr-close.yml` which deletes the corresponding `pr-` theme from +Shopify to avoid orphaned preview themes. + +### Updating Playwright snapshots + +If your changes intentionally modify the visual appearance of the site you +should update the Playwright baselines. Run the tests locally with +`npx playwright test --update-snapshots` and commit the updated snapshots. +Pull request previews will then use the new baselines when comparing +screenshots. + +### Staging deployments + +Pushing to the `staging` branch triggers the deployment workflow in +`deploy-staging.yml`. This workflow uploads the entire repository to the +staging theme ID supplied via `SHOPIFY_STAGING_THEME_ID`. No new theme is +created; the existing staging theme is updated in place. Live themes are +intentionally never modified from CI. + +### Live deployments + +There is no GitHub Actions workflow that deploys to the live theme. To +promote a staging theme to live you must use the Shopify Admin UI. This +design prevents accidental overwrites of the production storefront. \ No newline at end of file diff --git a/scripts/create_meta_definitions.mjs b/scripts/create_meta_definitions.mjs new file mode 100644 index 0000000..2fc472f --- /dev/null +++ b/scripts/create_meta_definitions.mjs @@ -0,0 +1,248 @@ +import fetch from 'cross-fetch'; + +/* + * Shopify Admin GraphQL metaobject and metafield setup. + * + * This script creates a handful of metaobject definitions and product metafield + * definitions used by the QFlex theme. It is designed to be idempotent: if a + * definition already exists the request will simply return a user error and + * the script will move on without interrupting the remaining setup. At the + * end of execution the process will exit with a non‑zero status if any + * unrecoverable errors occurred. + * + * Required environment variables: + * SHOPIFY_STORE – your *.myshopify.com subdomain (e.g. getqflex) + * SHOPIFY_ADMIN_ACCESS_TOKEN – an Admin API access token + * SHOPIFY_API_VERSION – the API version to target (e.g. 2024-07) + */ + +const { + SHOPIFY_STORE, + SHOPIFY_ADMIN_ACCESS_TOKEN, + SHOPIFY_API_VERSION, +} = process.env; + +function assertEnv() { + const missing = []; + if (!SHOPIFY_STORE) missing.push('SHOPIFY_STORE'); + if (!SHOPIFY_ADMIN_ACCESS_TOKEN) missing.push('SHOPIFY_ADMIN_ACCESS_TOKEN'); + if (!SHOPIFY_API_VERSION) missing.push('SHOPIFY_API_VERSION'); + if (missing.length) { + console.error(`Missing environment variables: ${missing.join(', ')}`); + process.exit(1); + } +} + +async function shopifyGraphQL(query, variables = {}) { + const res = await fetch( + `https://${SHOPIFY_STORE}.myshopify.com/admin/api/${SHOPIFY_API_VERSION}/graphql.json`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Shopify-Access-Token': SHOPIFY_ADMIN_ACCESS_TOKEN, + }, + body: JSON.stringify({ query, variables }), + } + ); + if (!res.ok) { + throw new Error(`GraphQL request failed: ${res.status} ${res.statusText}`); + } + const body = await res.json(); + return body; +} + +async function createMetaobject(definition) { + const mutation = ` + mutation MetaobjectDefinitionCreate($definition: MetaobjectDefinitionCreateInput!) { + metaobjectDefinitionCreate(definition: $definition) { + metaobjectDefinition { + id + } + userErrors { + field + message + } + } + } + `; + const { data, errors } = await shopifyGraphQL(mutation, { definition }); + if (errors) { + throw new Error(JSON.stringify(errors)); + } + const result = data.metaobjectDefinitionCreate; + if (result.userErrors && result.userErrors.length) { + // silently ignore already‑exists errors, otherwise log + const nonDupeErrors = result.userErrors.filter( + (err) => !/type.*already exists/i.test(err.message) + ); + if (nonDupeErrors.length) { + console.error( + `Metaobject ${definition.type} creation errors:`, + JSON.stringify(nonDupeErrors, null, 2) + ); + throw new Error('Metaobject creation failed'); + } + } +} + +async function createMetafield(definition) { + const mutation = ` + mutation MetafieldDefinitionCreate($definition: MetafieldDefinitionInput!) { + metafieldDefinitionCreate(definition: $definition) { + createdDefinition { + id + } + userErrors { + field + message + } + } + } + `; + const { data, errors } = await shopifyGraphQL(mutation, { definition }); + if (errors) { + throw new Error(JSON.stringify(errors)); + } + const result = data.metafieldDefinitionCreate; + if (result.userErrors && result.userErrors.length) { + const nonDupeErrors = result.userErrors.filter( + (err) => !/definition.*already exists/i.test(err.message) + ); + if (nonDupeErrors.length) { + console.error( + `Metafield ${definition.key} creation errors:`, + JSON.stringify(nonDupeErrors, null, 2) + ); + throw new Error('Metafield creation failed'); + } + } +} + +async function main() { + assertEnv(); + // Define metaobjects + const metaobjects = [ + { + name: 'Badge', + type: 'badge', + fieldDefinitions: [ + { + key: 'label', + name: 'Label', + description: 'Short text for the badge label', + type: 'single_line_text_field', + required: true, + }, + { + key: 'icon', + name: 'Icon', + description: 'Icon file for the badge', + type: 'file_reference', + required: true, + }, + { + key: 'color', + name: 'Color', + description: 'Optional color name for the badge', + type: 'single_line_text_field', + required: false, + }, + ], + }, + { + name: 'Testimonial', + type: 'testimonial', + fieldDefinitions: [ + { + key: 'name', + name: 'Name', + type: 'single_line_text_field', + description: 'Name of the person giving the testimonial', + required: true, + }, + { + key: 'quote', + name: 'Quote', + type: 'multi_line_text_field', + description: 'The testimonial text', + required: true, + }, + { + key: 'pain_area', + name: 'Pain Area', + type: 'single_line_text_field', + description: 'The customer pain area addressed', + required: true, + }, + ], + }, + { + name: 'Press logo', + type: 'press_logo', + fieldDefinitions: [ + { + key: 'image', + name: 'Image', + type: 'file_reference', + description: 'Logo image', + required: true, + }, + { + key: 'url', + name: 'URL', + type: 'url', + description: 'Link to the press article', + required: true, + }, + ], + }, + ]; + for (const def of metaobjects) { + await createMetaobject(def); + } + // Define product metafields in namespace qf + const metafields = [ + { + name: 'Hero eyebrow', + namespace: 'qf', + key: 'hero_eyebrow', + type: 'single_line_text_field', + description: 'Eyebrow text displayed above product title', + ownerType: 'PRODUCT', + }, + { + name: 'Review count', + namespace: 'qf', + key: 'review_count', + type: 'number_integer', + description: 'Total number of reviews', + ownerType: 'PRODUCT', + }, + { + name: 'Average rating', + namespace: 'qf', + key: 'avg_rating', + type: 'number_decimal', + description: 'Average customer rating', + ownerType: 'PRODUCT', + }, + { + name: 'USP pills', + namespace: 'qf', + key: 'usp_pills', + type: 'list.single_line_text_field', + description: 'Unique selling proposition pills', + ownerType: 'PRODUCT', + }, + ]; + for (const def of metafields) { + await createMetafield(def); + } + console.log('Metaobject and metafield definitions ensured.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); \ No newline at end of file diff --git a/sections/qflex-home.liquid b/sections/qflex-home.liquid new file mode 100644 index 0000000..f5beb4f --- /dev/null +++ b/sections/qflex-home.liquid @@ -0,0 +1,36 @@ +{%- comment -%} + QFlex home section displays hero eyebrow, rating, review count and USP pills. + Values are pulled from product metafields in namespace `qf` with sensible + defaults from section settings when metafields are absent. This ensures + merchants can manage content via Dynamic Sources while maintaining + backwards‑compatible fallbacks. +{%- endcomment -%} + +{%- liquid + assign hero_eyebrow = product.metafields.qf.hero_eyebrow.value | default: section.settings.eyebrow_text + assign avg_rating_raw = product.metafields.qf.avg_rating.value | default: 4.8 + assign avg_rating = avg_rating_raw | plus: 0 | round: 1 + assign review_count = product.metafields.qf.review_count.value | default: 2000 + if product.metafields.qf.usp_pills.value != blank + assign usp_items = product.metafields.qf.usp_pills.value + else + assign usp_items = section.settings.usp_pills + endif +-%} + +
+ {%- if hero_eyebrow != blank -%} +

{{ hero_eyebrow }}

+ {%- endif -%} +

+ {{ avg_rating }}/5 + ({{ review_count }}) +

+ {%- if usp_items != blank -%} +
    + {%- for usp in usp_items -%} +
  • {{ usp }}
  • + {%- endfor -%} +
+ {%- endif -%} +
\ No newline at end of file diff --git a/sections/qflex-pdp.liquid b/sections/qflex-pdp.liquid new file mode 100644 index 0000000..d582fe9 --- /dev/null +++ b/sections/qflex-pdp.liquid @@ -0,0 +1,37 @@ +{%- comment -%} + QFlex product detail page (PDP) section. + This section renders product‑level meta content such as the hero eyebrow, + average rating, review count and unique selling propositions (USP) pills. + Metafields in the `qf` namespace take precedence; if they are empty + the section falls back to its own configurable settings. Formatting of + ratings is normalised to one decimal place. +{%- endcomment -%} + +{%- liquid + assign hero_eyebrow = product.metafields.qf.hero_eyebrow.value | default: section.settings.eyebrow_text + assign avg_rating_raw = product.metafields.qf.avg_rating.value | default: 4.8 + assign avg_rating = avg_rating_raw | plus: 0 | round: 1 + assign review_count = product.metafields.qf.review_count.value | default: 2000 + if product.metafields.qf.usp_pills.value != blank + assign usp_items = product.metafields.qf.usp_pills.value + else + assign usp_items = section.settings.usp_pills + endif +-%} + +
+ {%- if hero_eyebrow != blank -%} +

{{ hero_eyebrow }}

+ {%- endif -%} +

+ {{ avg_rating }}/5 + ({{ review_count }}) +

+ {%- if usp_items != blank -%} +
    + {%- for usp in usp_items -%} +
  • {{ usp }}
  • + {%- endfor -%} +
+ {%- endif -%} +
\ No newline at end of file