first commit
1
.config/shopify-cli-app-command-nodejs/config.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
18
.config/shopify-cli-kit-nodejs/config.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"cache": {
|
||||||
|
"rate-limited-occurrences-send-error-to-bugsnag": {
|
||||||
|
"value": [
|
||||||
|
1779040828657,
|
||||||
|
1779040839929,
|
||||||
|
1779040845225,
|
||||||
|
1779040846291,
|
||||||
|
1779040846470
|
||||||
|
],
|
||||||
|
"timestamp": 1779040846470
|
||||||
|
},
|
||||||
|
"most-recent-occurrence-warn-on-multiple-versions": {
|
||||||
|
"value": true,
|
||||||
|
"timestamp": 1779040828354
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Environment Configuration
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Dependency directory
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Test coverage directory
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Ignore Apple macOS Desktop Services Store
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# extensions build output
|
||||||
|
extensions/*/build
|
||||||
|
extensions/*/dist
|
||||||
|
|
||||||
|
# lock files
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Ignore shopify files created during app dev
|
||||||
|
.shopify/*
|
||||||
|
.shopify.lock
|
||||||
30
.graphqlrc.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const fs = require("node:fs");
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
const config = {
|
||||||
|
projects: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
let extensions = [];
|
||||||
|
try {
|
||||||
|
extensions = fs.readdirSync("./extensions");
|
||||||
|
} catch {
|
||||||
|
// ignore if no extensions
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of extensions) {
|
||||||
|
const extensionPath = `./extensions/${entry}`;
|
||||||
|
const schema = `${extensionPath}/schema.graphql`;
|
||||||
|
if (!fs.existsSync(schema)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
config.projects[entry] = {
|
||||||
|
schema,
|
||||||
|
documents: [`${extensionPath}/**/*.graphql`],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = getConfig();
|
||||||
4
.npmrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
engine-strict=true
|
||||||
|
auto-install-peers=true
|
||||||
|
shamefully-hoist=true
|
||||||
|
legacy-peer-deps=true
|
||||||
825
assistant.md
Normal file
@@ -0,0 +1,825 @@
|
|||||||
|
You’ve got a solid feature there — the main issue is that your setup is straddling two different eras of the admin UI extensions stack:
|
||||||
|
|
||||||
|
Your API version 2025-07 + @shopify/ui-extensions-react/admin usage is the old React-based model.
|
||||||
|
Your tsconfig.json + shopify.d.ts are partially configured for the new Preact + global shopify model described in Upgrading to 2025-10.
|
||||||
|
This mismatch can cause confusing type errors and runtime issues.
|
||||||
|
|
||||||
|
Below I’ll walk through:
|
||||||
|
|
||||||
|
What’s “off” in your current setup.
|
||||||
|
Two clear paths:
|
||||||
|
A. Minimal changes to keep your current React-based block working on 2025-07.
|
||||||
|
B. Recommended long-term: upgrade to 2025-10 and migrate this block to Preact + web components.
|
||||||
|
1. What’s “off” in your current config
|
||||||
|
a) API version vs implementation style
|
||||||
|
You have:
|
||||||
|
|
||||||
|
toml
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
# shopify.extension.toml
|
||||||
|
api_version = "2025-07"
|
||||||
|
...
|
||||||
|
target = "admin.order-details.block.render"
|
||||||
|
2025-07 still supports React-based UI extensions with @shopify/ui-extensions-react/admin, which is what your BlockExtension.jsx is using.
|
||||||
|
|
||||||
|
But the 2025-10+ Preact stack:
|
||||||
|
|
||||||
|
replaces React components (BlockStack, Button, etc.) with Polaris web components (<s-stack>, <s-button>, etc.),
|
||||||
|
and uses the global shopify object instead of useApi().
|
||||||
|
Your BlockExtension.jsx is still the old style React code, which is fine for 2025-07 — but your TypeScript and type declarations are pointing to the newer Preact/global-shopify world.
|
||||||
|
|
||||||
|
b) Mixing React extensions with Preact hooks
|
||||||
|
You’re doing:
|
||||||
|
|
||||||
|
js
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
8
|
||||||
|
9
|
||||||
|
10
|
||||||
|
11
|
||||||
|
12
|
||||||
|
13
|
||||||
|
14
|
||||||
|
import {
|
||||||
|
reactExtension,
|
||||||
|
BlockStack,
|
||||||
|
InlineStack,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
Banner,
|
||||||
|
Divider,
|
||||||
|
Heading,
|
||||||
|
Box,
|
||||||
|
Link,
|
||||||
|
useApi,
|
||||||
|
} from '@shopify/ui-extensions-react/admin';
|
||||||
|
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||||
|
For React-based extensions, Shopify’s docs expect:
|
||||||
|
|
||||||
|
reactExtension + React components, and
|
||||||
|
Hooks imported from react, not preact/hooks.
|
||||||
|
Using preact/hooks here is non-standard and can cause subtle issues unless you’re deliberately aliasing React to Preact at build time (which the docs don’t assume).
|
||||||
|
|
||||||
|
c) tsconfig.json and shopify.d.ts
|
||||||
|
Your tsconfig.json:
|
||||||
|
|
||||||
|
json
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsx: "react-jsx" with jsxImportSource: "preact" tells TypeScript/Babel to compile JSX as Preact, not React.
|
||||||
|
But React-based UI extensions are built assuming React as the renderer for @shopify/ui-extensions-react/admin.
|
||||||
|
Your shopify.d.ts:
|
||||||
|
|
||||||
|
ts
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
import '@shopify/ui-extensions';
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
declare module './src/BlockExtension.jsx' {
|
||||||
|
const shopify: import('@shopify/ui-extensions/admin.order-details.block.render').Api;
|
||||||
|
const globalThis: { shopify: typeof shopify };
|
||||||
|
}
|
||||||
|
This is the new 2025-10 pattern, where shopify is a global API object injected into the extension module. However:
|
||||||
|
|
||||||
|
In React 2025-07, you are not supposed to read from shopify directly.
|
||||||
|
Instead, you use useApi() from @shopify/ui-extensions-react/admin.
|
||||||
|
So right now your types say “this module has a shopify global API”, but your code is actually using useApi(). That’s just confusing for tooling.
|
||||||
|
|
||||||
|
2. Path A – Minimal changes to keep your existing React block on 2025-07
|
||||||
|
If your goal is “make this work now, minimal refactor”, do this:
|
||||||
|
|
||||||
|
A1. Use React hooks, not Preact hooks
|
||||||
|
Change this:
|
||||||
|
|
||||||
|
js
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||||
|
To this:
|
||||||
|
|
||||||
|
js
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
import React, {useState, useEffect, useCallback} from 'react';
|
||||||
|
And ensure you have these dependencies in package.json (versions per Upgrading to 2025-10, but locked to 2025.4 or 2025.7):
|
||||||
|
|
||||||
|
json
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
8
|
||||||
|
9
|
||||||
|
10
|
||||||
|
11
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"@shopify/ui-extensions": "2025.4.x", // or 2025.7.x
|
||||||
|
"@shopify/ui-extensions-react": "2025.4.x", // or 2025.7.x
|
||||||
|
"react-reconciler": "0.29.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
A2. Simplify tsconfig.json for React
|
||||||
|
For a React-based extension, a simple tsconfig.json in the extension directory is enough:
|
||||||
|
|
||||||
|
json
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
8
|
||||||
|
9
|
||||||
|
10
|
||||||
|
11
|
||||||
|
12
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"target": "ES2020",
|
||||||
|
"checkJs": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Remove jsxImportSource: "preact" — you’re not compiling JSX as Preact here.
|
||||||
|
A3. Remove the shopify.d.ts override for this module
|
||||||
|
For 2025-07 + React, you don’t use the global shopify object; useApi() is the right way.
|
||||||
|
|
||||||
|
You can delete or comment out this shopify.d.ts:
|
||||||
|
|
||||||
|
ts
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
import '@shopify/ui-extensions';
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
declare module './src/BlockExtension.jsx' {
|
||||||
|
const shopify: import('@shopify/ui-extensions/admin.order-details.block.render').Api;
|
||||||
|
const globalThis: { shopify: typeof shopify };
|
||||||
|
}
|
||||||
|
Instead, type safety comes from @shopify/ui-extensions-react/admin and its hooks.
|
||||||
|
|
||||||
|
A4. Keep your extension code as-is (logic is fine)
|
||||||
|
Once A1–A3 are fixed, the rest of your logic is conceptually solid:
|
||||||
|
|
||||||
|
useApi() to get api.data.selected?.[0]?.id for the order ID.
|
||||||
|
api.query(...) to read metafields (hutko/fiscal_receipts, fiscal_info_*, fiscal_storage_*).
|
||||||
|
Local helpers openBase64AsBlob and downloadBase64AsFile.
|
||||||
|
Rendering a nice stack of receipts.
|
||||||
|
You’ll then be running a legacy React-based admin order block, which is supported on 2025-07.
|
||||||
|
|
||||||
|
3. Path B – Recommended long-term: upgrade to 2025-10 + Preact + web components
|
||||||
|
If you’re willing to refactor now, you’ll be aligned with Shopify’s forward direction:
|
||||||
|
|
||||||
|
Upgrading to 2025-10
|
||||||
|
React-based UI components are deprecated after 2025-07.
|
||||||
|
Preact + Polaris web components (<s-admin-block>, <s-stack>, <s-banner>, <s-button>, etc.) are the new model.
|
||||||
|
At a high level, you would:
|
||||||
|
|
||||||
|
B1. Update shopify.extension.toml
|
||||||
|
toml
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
8
|
||||||
|
9
|
||||||
|
10
|
||||||
|
11
|
||||||
|
12
|
||||||
|
13
|
||||||
|
14
|
||||||
|
api_version = "2025-10"
|
||||||
|
|
||||||
|
[[extensions]]
|
||||||
|
name = "t:name"
|
||||||
|
handle = "fiscal-order-block"
|
||||||
|
type = "ui_extension"
|
||||||
|
uid = "dfc94970-d01c-b839-2220-ffd1b6ab1c86ac3cce4a"
|
||||||
|
|
||||||
|
[[extensions.targeting]]
|
||||||
|
module = "./src/BlockExtension.jsx"
|
||||||
|
target = "admin.order-details.block.render"
|
||||||
|
|
||||||
|
[extensions.capabilities]
|
||||||
|
network_access = true
|
||||||
|
B2. Update dependencies to Preact + @shopify/ui-extensions (no React)
|
||||||
|
From the docs:
|
||||||
|
|
||||||
|
json
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "^10.10.x",
|
||||||
|
"@preact/signals": "^2.3.x",
|
||||||
|
"@shopify/ui-extensions": "2025.10.x"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
B3. Keep your current tsconfig.json (it’s already Preact-ready)
|
||||||
|
Your current tsconfig.json is already aligned with the Preact model from the upgrade guide:
|
||||||
|
|
||||||
|
json
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
8
|
||||||
|
9
|
||||||
|
10
|
||||||
|
11
|
||||||
|
12
|
||||||
|
13
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"target": "ES2020",
|
||||||
|
"checkJs": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
That’s exactly what Shopify suggests for Preact-based extensions.
|
||||||
|
|
||||||
|
B4. Rewrite BlockExtension.jsx to:
|
||||||
|
Use import '@shopify/ui-extensions/preact'; and render from preact.
|
||||||
|
Use Polaris web components instead of React components:
|
||||||
|
BlockStack → <s-stack>
|
||||||
|
InlineStack → <s-stack direction="inline">
|
||||||
|
Box → <s-box>
|
||||||
|
Banner → <s-banner>
|
||||||
|
Button → <s-button>
|
||||||
|
Link → <s-link>
|
||||||
|
Divider → <s-divider>
|
||||||
|
etc.
|
||||||
|
Use the global shopify object instead of useApi():
|
||||||
|
const orderId = shopify.data.selected?.[0]?.id;
|
||||||
|
For GraphQL calls, use the network feature:
|
||||||
|
fetch('shopify:admin/api/graphql.json', { ... }).
|
||||||
|
You’d also then use shopify.d.ts to type the global shopify object, but in the new style.
|
||||||
|
|
||||||
|
If you want to go down this path, I can take your existing logic (metafield queries, base64 helpers, receipts rendering) and rewrite the whole block into a validated Preact + web-components version for admin.order-details.block.render.
|
||||||
|
|
||||||
|
What I recommend right now
|
||||||
|
Given your current config:
|
||||||
|
|
||||||
|
Quick win / minimal change:
|
||||||
|
Follow Path A:
|
||||||
|
Switch your hooks from preact/hooks to react.
|
||||||
|
Adjust tsconfig.json to be React-oriented.
|
||||||
|
Remove the shopify.d.ts global override.
|
||||||
|
Ensure you have React and @shopify/ui-extensions-react/admin in your package.json.
|
||||||
|
That should get your existing block working reliably on 2025-07.
|
||||||
|
|
||||||
|
Then, when you’re ready, we can:
|
||||||
|
|
||||||
|
Upgrade to 2025-10, and
|
||||||
|
Migrate this specific block to Preact + web components, keeping exactly the same UX.
|
||||||
|
If you tell me which path you want (A: keep React on 2025-07, or B: migrate now to Preact/2025-10), I’ll either:
|
||||||
|
|
||||||
|
Give you the exact diffs to make your current React block correct, or
|
||||||
|
Provide a full Preact + <s-*> implementation of your fiscal receipts block for admin.order-details.block.render and explain the changes line-by-line.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
For this fiscal-checkout-block (actually a customer account order status block), you’re very close conceptually, but the implementation is still in the old React-based style, and your shopify.extension.toml is pinned to 2025-07, not 2025-10.
|
||||||
|
|
||||||
|
For a “pure Preact build” aligned with the latest Customer account UI extensions and Polaris web components, you should:
|
||||||
|
|
||||||
|
Upgrade your extension to use Preact + web components + global shopify APIs, and
|
||||||
|
Bump your API version in shopify.extension.toml.
|
||||||
|
Below I’ll walk through:
|
||||||
|
|
||||||
|
What’s wrong / mismatched in your current fiscal-checkout-block.
|
||||||
|
A validated Preact + web components rewrite of OrderStatusExtension.jsx for customer-account.order-status.block.render.
|
||||||
|
The shopify.extension.toml change you’ll want for a 2025‑10 style build.
|
||||||
|
Notes on your GraphQL + metafield access.
|
||||||
|
1. Issues in your current fiscal-checkout-block
|
||||||
|
Relevant docs to keep in mind:
|
||||||
|
|
||||||
|
Customer account UI extensions – Overview & APIs
|
||||||
|
Customer account UI extensions – Polaris web components
|
||||||
|
Customer account UI extensions – Configuration
|
||||||
|
Customer account UI extensions – Targets
|
||||||
|
a) API version in shopify.extension.toml
|
||||||
|
You have:
|
||||||
|
|
||||||
|
toml
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
api_version = "2025-07"
|
||||||
|
...
|
||||||
|
target = "customer-account.order-status.block.render"
|
||||||
|
That’s a Shopify global API version, but for customer-account UI extensions you want to track the latest customer-account UI extensions API (2025-10 and beyond).
|
||||||
|
Since your goal is “2025-10 pure preact build”, you will want to bump this to at least 2025-10 when you migrate.
|
||||||
|
(I’ll show what to change after the code.)
|
||||||
|
|
||||||
|
b) Using React wrappers + Preact hooks instead of the Preact/web‑components model
|
||||||
|
Your current code:
|
||||||
|
|
||||||
|
js
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
8
|
||||||
|
9
|
||||||
|
10
|
||||||
|
11
|
||||||
|
12
|
||||||
|
13
|
||||||
|
14
|
||||||
|
15
|
||||||
|
import {
|
||||||
|
reactExtension,
|
||||||
|
BlockStack,
|
||||||
|
InlineStack,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
Banner,
|
||||||
|
Divider,
|
||||||
|
Heading,
|
||||||
|
View,
|
||||||
|
Link,
|
||||||
|
Spinner,
|
||||||
|
useApi,
|
||||||
|
} from '@shopify/ui-extensions-react/customer-account';
|
||||||
|
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||||
|
Problems:
|
||||||
|
|
||||||
|
reactExtension + React components (BlockStack, Button, etc.) are part of the React-based API, not the web components model.
|
||||||
|
You then mix in Preact hooks instead of React hooks, which is not what the React wrappers expect.
|
||||||
|
For the 2025-10+ Polaris UI Framework, customer account extensions are intended to use:
|
||||||
|
import '@shopify/ui-extensions/preact';
|
||||||
|
render from preact
|
||||||
|
Polaris web components: <s-stack>, <s-text>, <s-banner>, <s-button>, <s-link>, <s-divider>, etc.
|
||||||
|
The global shopify object for contextual APIs (like shopify.order).
|
||||||
|
c) useApi() vs global shopify signals
|
||||||
|
You currently do:
|
||||||
|
|
||||||
|
js
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
function Extension() {
|
||||||
|
const { order } = useApi();
|
||||||
|
const orderId = order?.id?.value || order?.id;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
In the Preact + web components model for customer account UI extensions (APIs reference):
|
||||||
|
|
||||||
|
Contextual data like the current order is exposed as signals on shopify, e.g. shopify.order.
|
||||||
|
APIs with a .value property are signals: you access shopify.order.value inside your component.
|
||||||
|
So in the Preact model you’ll want something like:
|
||||||
|
|
||||||
|
js
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
const order = shopify.order.value;
|
||||||
|
const orderId = order?.id;
|
||||||
|
2. Migrated Preact + web components OrderStatusExtension.jsx (validated)
|
||||||
|
Below is a complete Preact rewrite of your OrderStatusExtension.jsx for the target customer-account.order-status.block.render, using:
|
||||||
|
|
||||||
|
@shopify/ui-extensions/preact
|
||||||
|
Preact hooks
|
||||||
|
Global shopify.order
|
||||||
|
Polaris web components:
|
||||||
|
<s-box>, <s-stack>, <s-spinner>, <s-text>, <s-banner>, <s-divider>, <s-button>, <s-link>
|
||||||
|
This code has been validated against the polaris-customer-account-extensions API for the target customer-account.order-status.block.render.
|
||||||
|
|
||||||
|
tsx
|
||||||
|
Copy
|
||||||
|
197
|
||||||
|
198
|
||||||
|
199
|
||||||
|
200
|
||||||
|
201
|
||||||
|
202
|
||||||
|
203
|
||||||
|
204
|
||||||
|
205
|
||||||
|
206
|
||||||
|
207
|
||||||
|
208
|
||||||
|
209
|
||||||
|
210
|
||||||
|
211
|
||||||
|
212
|
||||||
|
213
|
||||||
|
214
|
||||||
|
215
|
||||||
|
216
|
||||||
|
217
|
||||||
|
218
|
||||||
|
219
|
||||||
|
220
|
||||||
|
221
|
||||||
|
222
|
||||||
|
223
|
||||||
|
224
|
||||||
|
225
|
||||||
|
226
|
||||||
|
227
|
||||||
|
228
|
||||||
|
229
|
||||||
|
230
|
||||||
|
231
|
||||||
|
232
|
||||||
|
233
|
||||||
|
234
|
||||||
|
235
|
||||||
|
236
|
||||||
|
237
|
||||||
|
238
|
||||||
|
239
|
||||||
|
240
|
||||||
|
241
|
||||||
|
242
|
||||||
|
243
|
||||||
|
244
|
||||||
|
245
|
||||||
|
246
|
||||||
|
247
|
||||||
|
248
|
||||||
|
249
|
||||||
|
250
|
||||||
|
251
|
||||||
|
252
|
||||||
|
253
|
||||||
|
254
|
||||||
|
255
|
||||||
|
256
|
||||||
|
257
|
||||||
|
258
|
||||||
|
259
|
||||||
|
260
|
||||||
|
261
|
||||||
|
262
|
||||||
|
263
|
||||||
|
264
|
||||||
|
265
|
||||||
|
266
|
||||||
|
267
|
||||||
|
268
|
||||||
|
269
|
||||||
|
270
|
||||||
|
271
|
||||||
|
272
|
||||||
|
273
|
||||||
|
274
|
||||||
|
275
|
||||||
|
276
|
||||||
|
277
|
||||||
|
278
|
||||||
|
279
|
||||||
|
280
|
||||||
|
281
|
||||||
|
282
|
||||||
|
283
|
||||||
|
284
|
||||||
|
285
|
||||||
|
import '@shopify/ui-extensions/preact';
|
||||||
|
{receipts.map((receipt) => (
|
||||||
|
<s-box
|
||||||
|
key={receipt.id}
|
||||||
|
padding="base"
|
||||||
|
border="base"
|
||||||
|
border-radius="base"
|
||||||
|
>
|
||||||
|
<s-stack gap="base">
|
||||||
|
{/* Receipt summary */}
|
||||||
|
<s-stack gap="none">
|
||||||
|
{receipt.info.display_datetime ? (
|
||||||
|
<>
|
||||||
|
<s-text font-weight="bold">
|
||||||
|
{receipt.info.display_datetime}
|
||||||
|
</s-text>
|
||||||
|
{receipt.info.amount && (
|
||||||
|
<s-text>{receipt.info.amount} грн</s-text>
|
||||||
|
)}
|
||||||
|
{receipt.info.fiscal_number && (
|
||||||
|
<s-text>
|
||||||
|
ФН: {receipt.info.fiscal_number}
|
||||||
|
</s-text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<s-text>
|
||||||
|
Receipt {receipt.id} — waiting for fiscalisation
|
||||||
|
</s-text>
|
||||||
|
)}
|
||||||
|
</s-stack>
|
||||||
|
|
||||||
|
<s-divider />
|
||||||
|
|
||||||
|
{/* Action buttons */}
|
||||||
|
<s-stack direction="inline" gap="base">
|
||||||
|
{receipt.storage.html && (
|
||||||
|
<s-button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() =>
|
||||||
|
openBase64AsBlob(receipt.storage.html, 'text/html')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View Receipt
|
||||||
|
</s-button>
|
||||||
|
)}
|
||||||
|
{receipt.storage.xml && (
|
||||||
|
<s-button
|
||||||
|
onClick={() =>
|
||||||
|
downloadBase64AsFile(
|
||||||
|
receipt.storage.xml,
|
||||||
|
`fiscal_receipt_${receipt.id}.xml`,
|
||||||
|
'application/xml',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Download XML
|
||||||
|
</s-button>
|
||||||
|
)}
|
||||||
|
</s-stack>
|
||||||
|
|
||||||
|
{/* External links */}
|
||||||
|
{receipt.info.links && (
|
||||||
|
<s-stack direction="inline" gap="base">
|
||||||
|
{receipt.info.links.tax_service && (
|
||||||
|
<s-link href={receipt.info.links.tax_service} target="_blank">
|
||||||
|
Tax Service
|
||||||
|
</s-link>
|
||||||
|
)}
|
||||||
|
{receipt.info.links.external_provider && (
|
||||||
|
<s-link
|
||||||
|
href={receipt.info.links.external_provider}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Provider
|
||||||
|
</s-link>
|
||||||
|
)}
|
||||||
|
{receipt.info.links.pdf && (
|
||||||
|
<s-link href={receipt.info.links.pdf} target="_blank">
|
||||||
|
PDF
|
||||||
|
</s-link>
|
||||||
|
)}
|
||||||
|
</s-stack>
|
||||||
|
)}
|
||||||
|
</s-stack>
|
||||||
|
</s-box>
|
||||||
|
))}
|
||||||
|
</s-stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Key differences vs your original:
|
||||||
|
|
||||||
|
Entry point:
|
||||||
|
Old: export default reactExtension(..., () => <Extension />);
|
||||||
|
New: export default function extension() { render(<Extension />, document.body); }
|
||||||
|
Context API:
|
||||||
|
Old: const { order } = useApi();
|
||||||
|
New: const order = shopify.order.value;
|
||||||
|
UI layer:
|
||||||
|
Old: React components (BlockStack, View, Button, Link, etc.).
|
||||||
|
New: Polaris web components: <s-stack>, <s-box>, <s-banner>, <s-button>, <s-link>, etc.
|
||||||
|
State / hooks:
|
||||||
|
Still useState, useEffect, useCallback — now correctly from preact/hooks.
|
||||||
|
3. Updating shopify.extension.toml for a 2025‑10 Preact build
|
||||||
|
Currently:
|
||||||
|
|
||||||
|
toml
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
8
|
||||||
|
9
|
||||||
|
10
|
||||||
|
11
|
||||||
|
12
|
||||||
|
13
|
||||||
|
14
|
||||||
|
15
|
||||||
|
16
|
||||||
|
17
|
||||||
|
18
|
||||||
|
api_version = "2025-07"
|
||||||
|
uid = "ca81c72e-43fb-0709-a9a3-373f38c511faf3b68f36"
|
||||||
|
|
||||||
|
[[extensions]]
|
||||||
|
name = "Fiscal Receipt Info"
|
||||||
|
handle = "fiscal-checkout-block"
|
||||||
|
type = "ui_extension"
|
||||||
|
|
||||||
|
[[extensions.targeting]]
|
||||||
|
module = "./src/OrderStatusExtension.jsx"
|
||||||
|
target = "customer-account.order-status.block.render"
|
||||||
|
|
||||||
|
[extensions.capabilities]
|
||||||
|
network_access = true
|
||||||
|
|
||||||
|
[[extensions.metafields]]
|
||||||
|
namespace = "hutko"
|
||||||
|
key = "fiscal_receipts"
|
||||||
|
For a Preact + web components build aligned with the latest customer account UI extensions API, update to:
|
||||||
|
|
||||||
|
toml
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
8
|
||||||
|
9
|
||||||
|
10
|
||||||
|
11
|
||||||
|
12
|
||||||
|
13
|
||||||
|
14
|
||||||
|
15
|
||||||
|
16
|
||||||
|
17
|
||||||
|
18
|
||||||
|
api_version = "2025-10"
|
||||||
|
uid = "ca81c72e-43fb-0709-a9a3-373f38c511faf3b68f36"
|
||||||
|
|
||||||
|
[[extensions]]
|
||||||
|
name = "Fiscal Receipt Info"
|
||||||
|
handle = "fiscal-checkout-block"
|
||||||
|
type = "ui_extension"
|
||||||
|
|
||||||
|
[[extensions.targeting]]
|
||||||
|
module = "./src/OrderStatusExtension.jsx"
|
||||||
|
target = "customer-account.order-status.block.render"
|
||||||
|
|
||||||
|
[extensions.capabilities]
|
||||||
|
network_access = true
|
||||||
|
|
||||||
|
[[extensions.metafields]]
|
||||||
|
namespace = "hutko"
|
||||||
|
key = "fiscal_receipts"
|
||||||
|
Keep the same target (customer-account.order-status.block.render), which is one of the documented customer account targets.
|
||||||
|
Just bump api_version and ensure your package.json has the Preact dependencies:
|
||||||
|
json
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "^10.10.x",
|
||||||
|
"@preact/signals": "^2.3.x",
|
||||||
|
"@shopify/ui-extensions": "2025.10.x"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
4. Notes on your GraphQL and metafield usage
|
||||||
|
Your original logic:
|
||||||
|
|
||||||
|
Index metafield:
|
||||||
|
Operation
|
||||||
|
Open in GraphiQL
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
query getOrderMetafield($id: ID!) {
|
||||||
|
order(id: $id) {
|
||||||
|
metafield(namespace: "hutko", key: "fiscal_receipts") { value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Per-receipt metafields:
|
||||||
|
Operation
|
||||||
|
Open in GraphiQL
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
query getReceiptData($orderId: ID!, $infoKey: String!, $storageKey: String!) {
|
||||||
|
order(id: $orderId) {
|
||||||
|
info: metafield(namespace: "hutko", key: $infoKey) { value }
|
||||||
|
storage: metafield(namespace: "hutko", key: $storageKey) { value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
and then JSON parse on each value.
|
||||||
|
|
||||||
|
This pattern is totally fine to keep in the Preact version — the only changes I made are:
|
||||||
|
|
||||||
|
Bumping API_VERSION from '2025-07' to '2025-10'.
|
||||||
|
Using shopify.order.value rather than useApi() to get orderId.
|
||||||
|
If your metafield schema is already live in production, this Preact migration should not require any back‑end changes.
|
||||||
|
|
||||||
|
What to do next
|
||||||
|
Replace your OrderStatusExtension.jsx with the Preact/web-components version above.
|
||||||
|
Update shopify.extension.toml to api_version = "2025-10".
|
||||||
|
Ensure package.json for this extension has:
|
||||||
|
preact, @preact/signals, @shopify/ui-extensions (and no @shopify/ui-extensions-react/...).
|
||||||
|
Run:
|
||||||
|
bash
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
shopify app dev
|
||||||
|
And test on the Order status page under the customer-account.order-status.block.render target.
|
||||||
|
|
||||||
|
If you’d like, you can paste your package.json for this extension and I’ll align it exactly with the 2025‑10 Preact + customer-account UI extensions stack, and we can similarly migrate your admin.order-details.block.render block so both sides (admin + customer account) use the same Preact/web-components approach.
|
||||||
|
|
||||||
|
Sources
|
||||||
|
API: Customer Account Ui Extensions
|
||||||
|
Customer account UI extensions
|
||||||
|
API: Customer Account Ui Extensions > Polaris Web Components
|
||||||
|
Polaris web components
|
||||||
|
API: Customer Account Ui Extensions > Configuration
|
||||||
|
Customer account UI extensions – Configuration
|
||||||
18
extensions/fiscal-checkout-block/locales/en.default.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "Fiscal Order Status Block",
|
||||||
|
"hutko": {
|
||||||
|
"fiscal_receipts": "Fiscal Receipts",
|
||||||
|
"loading": "Loading fiscal receipts...",
|
||||||
|
"no_receipts": "No fiscal receipts found.",
|
||||||
|
"view_receipt": "View Receipt",
|
||||||
|
"download_xml": "Download XML",
|
||||||
|
"tax_service": "Tax Service",
|
||||||
|
"provider": "Provider",
|
||||||
|
"pdf": "PDF",
|
||||||
|
"waiting_for_fiscalisation": "Receipt {{id}} — waiting for fiscalisation",
|
||||||
|
"failed_to_load_receipts": "Failed to load fiscal receipts. Please try again.",
|
||||||
|
"currency": "UAH",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"fiscal_number_label": "FN: "
|
||||||
|
}
|
||||||
|
}
|
||||||
18
extensions/fiscal-checkout-block/locales/uk.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "Блок статусу фіскального замовлення",
|
||||||
|
"hutko": {
|
||||||
|
"fiscal_receipts": "Фіскальні чеки",
|
||||||
|
"loading": "Завантаження фіскальних чеків...",
|
||||||
|
"no_receipts": "Фіскальних чеків не знайдено.",
|
||||||
|
"view_receipt": "Переглянути чек",
|
||||||
|
"download_xml": "Завантажити XML",
|
||||||
|
"tax_service": "Податкова служба",
|
||||||
|
"provider": "Провайдер",
|
||||||
|
"pdf": "PDF",
|
||||||
|
"waiting_for_fiscalisation": "Чек {{id}} — очікує фіскалізації",
|
||||||
|
"failed_to_load_receipts": "Не вдалося завантажити фіскальні чеки. Будь ласка, спробуйте ще раз.",
|
||||||
|
"currency": "грн",
|
||||||
|
"refresh": "Оновити",
|
||||||
|
"fiscal_number_label": "ФН: "
|
||||||
|
}
|
||||||
|
}
|
||||||
7
extensions/fiscal-checkout-block/shopify.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import '@shopify/ui-extensions';
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
declare module './src/OrderStatusExtension.jsx' {
|
||||||
|
const shopify: import('@shopify/ui-extensions/customer-account.order-status.block.render').Api;
|
||||||
|
const globalThis: { shopify: typeof shopify };
|
||||||
|
}
|
||||||
17
extensions/fiscal-checkout-block/shopify.extension.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
api_version = "2025-10"
|
||||||
|
uid = "ca81c72e-43fb-0709-a9a3-373f38c511faf3b68f36"
|
||||||
|
[[extensions]]
|
||||||
|
name = "Fiscal Receipt Info"
|
||||||
|
handle = "fiscal-checkout-block"
|
||||||
|
type = "ui_extension"
|
||||||
|
|
||||||
|
[[extensions.targeting]]
|
||||||
|
module = "./src/OrderStatusExtension.jsx"
|
||||||
|
target = "customer-account.order-status.block.render"
|
||||||
|
|
||||||
|
[extensions.capabilities]
|
||||||
|
network_access = true
|
||||||
|
|
||||||
|
[[extensions.metafields]]
|
||||||
|
namespace = "hutko"
|
||||||
|
key = "fiscal_receipts"
|
||||||
293
extensions/fiscal-checkout-block/src/OrderStatusExtension.jsx
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
import '@shopify/ui-extensions/customer-account';
|
||||||
|
import { render } from 'preact';
|
||||||
|
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||||
|
|
||||||
|
function openBase64AsBlob(base64String, mimeType) {
|
||||||
|
try {
|
||||||
|
const decoded = atob(base64String);
|
||||||
|
const bytes = new Uint8Array(decoded.length);
|
||||||
|
for (let i = 0; i < decoded.length; i++) {
|
||||||
|
bytes[i] = decoded.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const blob = new Blob([bytes], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const win = window.open(url, '_blank');
|
||||||
|
if (win) {
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||||
|
}
|
||||||
|
return !!win;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to open blob:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBase64AsFile(base64String, filename, mimeType) {
|
||||||
|
try {
|
||||||
|
const decoded = atob(base64String);
|
||||||
|
const bytes = new Uint8Array(decoded.length);
|
||||||
|
for (let i = 0; i < decoded.length; i++) {
|
||||||
|
bytes[i] = decoded.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const blob = new Blob([bytes], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to download file:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function queryCustomerAccountAPI(query, variables = {}) {
|
||||||
|
const res = await fetch(
|
||||||
|
'shopify://customer-account/api/2025-10/graphql.json',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) throw new Error(`API request failed: ${res.status}`);
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function extension() {
|
||||||
|
render(<Extension />, document.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Extension() {
|
||||||
|
const translate = shopify.i18n.translate;
|
||||||
|
// Get order ID from the order status context signal
|
||||||
|
const orderId = shopify.order?.value?.id;
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [receipts, setReceipts] = useState([]);
|
||||||
|
|
||||||
|
const fetchFiscalData = useCallback(async () => {
|
||||||
|
if (!orderId) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Step 1: Read fiscal_receipts from pre-loaded appMetafields (declared in TOML)
|
||||||
|
let receiptIds = [];
|
||||||
|
const metafields = shopify.appMetafields?.value;
|
||||||
|
if (metafields && metafields.length > 0) {
|
||||||
|
const entry = metafields.find(
|
||||||
|
(e) =>
|
||||||
|
String(e.target?.type) === 'order' &&
|
||||||
|
e.metafield?.namespace === 'hutko' &&
|
||||||
|
e.metafield?.key === 'fiscal_receipts',
|
||||||
|
);
|
||||||
|
if (entry?.metafield?.value && typeof entry.metafield.value === 'string') {
|
||||||
|
try {
|
||||||
|
receiptIds = JSON.parse(entry.metafield.value);
|
||||||
|
} catch (e) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: query via Customer Account API if appMetafields didn't have it
|
||||||
|
if (receiptIds.length === 0) {
|
||||||
|
const indexRes = await queryCustomerAccountAPI(
|
||||||
|
`query getOrderMetafield($id: ID!) {
|
||||||
|
order(id: $id) {
|
||||||
|
metafield(namespace: "hutko", key: "fiscal_receipts") { value }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ id: orderId },
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
receiptIds = JSON.parse(
|
||||||
|
indexRes?.data?.order?.metafield?.value || '[]'
|
||||||
|
);
|
||||||
|
} catch (e) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receiptIds.length === 0) {
|
||||||
|
setReceipts([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Fetch per-receipt info & storage via Customer Account API
|
||||||
|
const detailed = await Promise.all(
|
||||||
|
receiptIds.map(async (id) => {
|
||||||
|
const res = await queryCustomerAccountAPI(
|
||||||
|
`query getReceiptData($orderId: ID!, $infoKey: String!, $storageKey: String!) {
|
||||||
|
order(id: $orderId) {
|
||||||
|
info: metafield(namespace: "hutko", key: $infoKey) { value }
|
||||||
|
storage: metafield(namespace: "hutko", key: $storageKey) { value }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
orderId,
|
||||||
|
infoKey: `fiscal_info_${id}`,
|
||||||
|
storageKey: `fiscal_storage_${id}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let info = {};
|
||||||
|
try {
|
||||||
|
info = JSON.parse(res?.data?.order?.info?.value || '{}');
|
||||||
|
} catch (e) { }
|
||||||
|
|
||||||
|
let storage = {};
|
||||||
|
try {
|
||||||
|
storage = JSON.parse(res?.data?.order?.storage?.value || '{}');
|
||||||
|
} catch (e) { }
|
||||||
|
|
||||||
|
return { id, info, storage };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setReceipts(detailed);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch fiscal data:', e);
|
||||||
|
setError(translate('hutko.failed_to_load_receipts'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [orderId, translate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFiscalData();
|
||||||
|
|
||||||
|
const t1 = setTimeout(fetchFiscalData, 5000);
|
||||||
|
const t2 = setTimeout(fetchFiscalData, 20000);
|
||||||
|
const t3 = setTimeout(fetchFiscalData, 40000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(t1);
|
||||||
|
clearTimeout(t2);
|
||||||
|
clearTimeout(t3);
|
||||||
|
};
|
||||||
|
}, [fetchFiscalData]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<s-box padding="base" border="base" borderRadius="base">
|
||||||
|
<s-stack direction="inline" alignItems="center" gap="base">
|
||||||
|
<s-spinner size="small"></s-spinner>
|
||||||
|
<s-text>{translate('hutko.loading')}</s-text>
|
||||||
|
</s-stack>
|
||||||
|
</s-box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <s-banner tone="critical">{error}</s-banner>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (receipts.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const logoSrc = "data:image/svg+xml,%3Csvg width='120' height='30' viewBox='188 423 624 154' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M747.38 473.832C762.386 473.832 774.926 478.66 784.944 488.305C794.962 497.957 800 509.817 800 523.877C800 537.938 794.962 549.594 784.944 559.246C774.876 568.897 762.386 573.72 747.38 573.72C732.374 573.72 719.898 568.947 709.923 559.344C699.955 549.742 694.967 537.938 694.967 523.871C694.967 509.803 700.005 498.104 710.023 488.403C720.095 478.713 732.504 473.835 747.38 473.824V473.832ZM474.833 530.079C474.833 535.826 476.46 540.445 479.713 543.992C482.966 547.531 487.373 549.332 492.984 549.333C499.491 549.333 504.687 547.433 508.621 543.634C512.555 539.835 514.547 534.648 514.547 528.032V475.729H540.883V571.51L540.832 571.461H514.705V561.963C507.467 569.407 497.972 573.15 486.27 573.15C474.568 573.15 465.653 569.61 458.781 562.524C451.909 555.438 448.498 546.102 448.498 534.55V475.721H474.833V530.079ZM266.725 535.497L322.589 501.641L256.912 571.921L255.929 543.519L200.008 571.921H200L269.09 426.28L266.725 535.497ZM375.332 485.276C382.57 477.832 392.065 474.089 403.768 474.089C415.47 474.089 424.384 477.629 431.256 484.715C438.128 491.802 441.539 501.138 441.539 512.689V571.517H415.204V517.161C415.204 511.413 413.577 506.794 410.324 503.248C407.071 499.708 402.664 497.907 397.053 497.907C390.546 497.907 385.35 499.806 381.416 503.605C377.482 507.404 375.49 512.591 375.49 519.208V571.51H349.154V475.729L349.205 452.648H375.332V485.276ZM588.437 475.729H611.991V498.88H588.443V537.326C588.443 541.531 589.44 544.616 591.482 546.459C593.532 548.31 596.836 549.284 601.407 549.284H611.991V571.51H599.508C574.434 571.51 561.943 560.576 561.943 538.658V498.929H547.833V475.729H561.943V452.647H588.437V475.729ZM645.649 518.639H645.707L678.126 475.729H708.238L670.674 523.315L709.75 571.461H676.75L645.649 532.09V571.461H618.941V475.729H645.649V518.639ZM747.38 497.389C739.877 497.389 733.635 499.905 728.598 504.938C723.567 509.971 721.044 516.279 721.044 523.877C721.044 531.475 723.517 537.994 728.39 543.027C733.27 548.059 739.404 550.569 746.807 550.569C754.209 550.569 760.666 548.003 765.904 542.921C771.1 537.84 773.722 531.475 773.722 523.877C773.722 516.279 771.207 509.964 766.169 504.938H766.162C761.132 499.905 754.883 497.389 747.38 497.389Z' fill='%23E40B2D'/%3E%3C/svg%3E";
|
||||||
|
|
||||||
|
return (<s-box>
|
||||||
|
<s-stack gap="base" padding="base" border="base" borderRadius="base">
|
||||||
|
|
||||||
|
<s-heading>{translate('hutko.fiscal_receipts')}</s-heading>
|
||||||
|
|
||||||
|
{receipts.map((receipt) => (
|
||||||
|
<s-box key={receipt.id} padding="base" border="base" borderRadius="base">
|
||||||
|
<s-stack gap="base">
|
||||||
|
<s-stack direction="inline" justifyContent="space-between" alignItems="center">
|
||||||
|
<s-stack gap="none">
|
||||||
|
{receipt.info.display_datetime ? (
|
||||||
|
<>
|
||||||
|
<s-text type="strong">{receipt.info.display_datetime}</s-text>
|
||||||
|
{receipt.info.amount && (
|
||||||
|
<s-text>{receipt.info.amount} {translate('hutko.currency')}</s-text>
|
||||||
|
)}
|
||||||
|
{receipt.info.fiscal_number && (
|
||||||
|
<s-text type="small">{translate('hutko.fiscal_number_label')}{receipt.info.fiscal_number}</s-text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<s-text>{translate('hutko.waiting_for_fiscalisation', { id: receipt.id })}</s-text>
|
||||||
|
)}
|
||||||
|
</s-stack>
|
||||||
|
</s-stack>
|
||||||
|
|
||||||
|
<s-divider></s-divider>
|
||||||
|
|
||||||
|
<s-stack direction="inline" gap="base">
|
||||||
|
{receipt.storage.html && (
|
||||||
|
<s-button
|
||||||
|
onClick={() =>
|
||||||
|
openBase64AsBlob(receipt.storage.html, 'text/html')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{translate('hutko.view_receipt')}
|
||||||
|
</s-button>
|
||||||
|
)}
|
||||||
|
{receipt.storage.xml && (
|
||||||
|
<s-button
|
||||||
|
onClick={() =>
|
||||||
|
downloadBase64AsFile(
|
||||||
|
receipt.storage.xml,
|
||||||
|
`fiscal_receipt_${receipt.id}.xml`,
|
||||||
|
'application/xml'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{translate('hutko.download_xml')}
|
||||||
|
</s-button>
|
||||||
|
)}
|
||||||
|
</s-stack>
|
||||||
|
|
||||||
|
{receipt.info.links && (
|
||||||
|
<s-stack direction="inline" gap="base">
|
||||||
|
{receipt.info.links.tax_service && (
|
||||||
|
<s-link href={receipt.info.links.tax_service} target="_blank">
|
||||||
|
{translate('hutko.tax_service')}
|
||||||
|
</s-link>
|
||||||
|
)}
|
||||||
|
{receipt.info.links.external_provider && (
|
||||||
|
<s-link href={receipt.info.links.external_provider} target="_blank">
|
||||||
|
{translate('hutko.provider')}
|
||||||
|
</s-link>
|
||||||
|
)}
|
||||||
|
{receipt.info.links.pdf && (
|
||||||
|
<s-link href={receipt.info.links.pdf} target="_blank">
|
||||||
|
{translate('hutko.pdf')}
|
||||||
|
</s-link>
|
||||||
|
)}
|
||||||
|
</s-stack>
|
||||||
|
)}
|
||||||
|
</s-stack>
|
||||||
|
</s-box>
|
||||||
|
))}
|
||||||
|
<s-stack direction="inline" justifyContent="space-between" alignItems="center">
|
||||||
|
<s-image
|
||||||
|
src={logoSrc}
|
||||||
|
alt="Hutko"
|
||||||
|
aspectRatio="4/1"
|
||||||
|
objectFit="contain"
|
||||||
|
sizes="120px"
|
||||||
|
inlineSize="auto"
|
||||||
|
loading="lazy"
|
||||||
|
></s-image>
|
||||||
|
<s-stack direction="inline" justifyContent="end">
|
||||||
|
<s-button
|
||||||
|
onClick={fetchFiscalData}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{translate('hutko.refresh')}
|
||||||
|
</s-button>
|
||||||
|
</s-stack>
|
||||||
|
</s-stack>
|
||||||
|
</s-stack></s-box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
13
extensions/fiscal-checkout-block/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"target": "ES2020",
|
||||||
|
"checkJs": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
5
extensions/fiscal-order-block/README.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Block Extension
|
||||||
|
|
||||||
|
Admin block extensions enable app developers to build custom functionality into the context of Shopify Admin. These extensions surface as cards at various extension targets and enhance the merchant experience. Developers can build the content of these extensions using Shopify's UI Extension components for Admin.
|
||||||
|
|
||||||
|
Learn more about Admin block extensions in Shopify’s [developer documentation](https://shopify.dev/docs/apps/admin/admin-actions-and-blocks).
|
||||||
20
extensions/fiscal-order-block/locales/en.default.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "Fiscal Order Block",
|
||||||
|
"hutko": {
|
||||||
|
"fiscal_receipts_title": "Hutko Fiscal Receipts",
|
||||||
|
"fiscal_receipts": "Fiscal Receipts",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"no_receipts": "No fiscal receipts found for this order.",
|
||||||
|
"view_receipt": "View Receipt",
|
||||||
|
"download_xml": "Download XML",
|
||||||
|
"tax_service": "Tax Service",
|
||||||
|
"provider": "Provider",
|
||||||
|
"pdf": "PDF",
|
||||||
|
"waiting_for_fiscalisation": "Receipt {{id}} — waiting for fiscalisation",
|
||||||
|
"no_order_selected": "No order selected.",
|
||||||
|
"failed_to_load_receipts": "Failed to load fiscal receipts: ",
|
||||||
|
"currency": "UAH",
|
||||||
|
"customer_actions": "Customer actions"
|
||||||
|
}
|
||||||
|
}
|
||||||
20
extensions/fiscal-order-block/locales/uk.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "Блок фіскальних чеків",
|
||||||
|
"hutko": {
|
||||||
|
"fiscal_receipts_title": "Фіскальні чеки Hutko",
|
||||||
|
"fiscal_receipts": "Фіскальні чеки",
|
||||||
|
"refresh": "Оновити",
|
||||||
|
"loading": "Завантаження...",
|
||||||
|
"no_receipts": "Для цього замовлення не знайдено фіскальних чеків.",
|
||||||
|
"view_receipt": "Переглянути чек",
|
||||||
|
"download_xml": "Завантажити XML",
|
||||||
|
"tax_service": "Податкова служба",
|
||||||
|
"provider": "Провайдер",
|
||||||
|
"pdf": "PDF",
|
||||||
|
"waiting_for_fiscalisation": "Чек {{id}} — очікує фіскалізації",
|
||||||
|
"no_order_selected": "Замовлення не вибрано.",
|
||||||
|
"failed_to_load_receipts": "Не вдалося завантажити фіскальні чеки: ",
|
||||||
|
"currency": "грн",
|
||||||
|
"customer_actions": "Дії клієнта"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
extensions/fiscal-order-block/shopify.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import '@shopify/ui-extensions';
|
||||||
|
|
||||||
|
//@ts-ignore
|
||||||
|
declare module './src/BlockExtension.jsx' {
|
||||||
|
const shopify: import('@shopify/ui-extensions/admin.order-details.block.render').Api;
|
||||||
|
const globalThis: { shopify: typeof shopify };
|
||||||
|
}
|
||||||
13
extensions/fiscal-order-block/shopify.extension.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
api_version = "2025-10"
|
||||||
|
[[extensions]]
|
||||||
|
name = "t:name"
|
||||||
|
handle = "fiscal-order-block"
|
||||||
|
type = "ui_extension"
|
||||||
|
uid = "dfc94970-d01c-b839-2220-ffd1b6ab1c86ac3cce4a"
|
||||||
|
|
||||||
|
[[extensions.targeting]]
|
||||||
|
module = "./src/BlockExtension.jsx"
|
||||||
|
target = "admin.order-details.block.render"
|
||||||
|
|
||||||
|
[extensions.capabilities]
|
||||||
|
network_access = true
|
||||||
252
extensions/fiscal-order-block/src/BlockExtension.jsx
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
import '@shopify/ui-extensions/preact';
|
||||||
|
import { render } from 'preact';
|
||||||
|
import { useState, useEffect, useCallback } from 'preact/hooks';
|
||||||
|
|
||||||
|
function openBase64AsBlob(base64String, mimeType) {
|
||||||
|
try {
|
||||||
|
const decoded = atob(base64String);
|
||||||
|
const bytes = new Uint8Array(decoded.length);
|
||||||
|
for (let i = 0; i < decoded.length; i++) {
|
||||||
|
bytes[i] = decoded.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const blob = new Blob([bytes], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const win = window.open(url, '_blank');
|
||||||
|
if (win) {
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||||
|
}
|
||||||
|
return !!win;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to open blob:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBase64AsFile(base64String, filename, mimeType) {
|
||||||
|
try {
|
||||||
|
const decoded = atob(base64String);
|
||||||
|
const bytes = new Uint8Array(decoded.length);
|
||||||
|
for (let i = 0; i < decoded.length; i++) {
|
||||||
|
bytes[i] = decoded.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const blob = new Blob([bytes], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to download file:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function extension() {
|
||||||
|
render(<Extension />, document.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Extension() {
|
||||||
|
const translate = shopify.i18n.translate;
|
||||||
|
const orderId = shopify.data.selected?.[0]?.id;
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const [receipts, setReceipts] = useState([]);
|
||||||
|
|
||||||
|
const fetchFiscalData = useCallback(async () => {
|
||||||
|
if (!orderId) {
|
||||||
|
setError(translate('hutko.no_order_selected'));
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const indexRes = await fetch('shopify:admin/api/graphql.json', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `
|
||||||
|
query getOrderMetafields($id: ID!) {
|
||||||
|
order(id: $id) {
|
||||||
|
receipts: metafield(namespace: "hutko", key: "fiscal_receipts") { value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
variables: { id: orderId }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const indexData = await indexRes.json();
|
||||||
|
|
||||||
|
const receiptIds = JSON.parse(
|
||||||
|
indexData?.data?.order?.receipts?.value || '[]'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (receiptIds.length === 0) {
|
||||||
|
setReceipts([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const detailed = await Promise.all(
|
||||||
|
receiptIds.map(async (id) => {
|
||||||
|
const res = await fetch('shopify:admin/api/graphql.json', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `
|
||||||
|
query getReceiptData($id: ID!, $infoKey: String!, $storageKey: String!) {
|
||||||
|
order(id: $id) {
|
||||||
|
info: metafield(namespace: "hutko", key: $infoKey) { value }
|
||||||
|
storage: metafield(namespace: "hutko", key: $storageKey) { value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
variables: {
|
||||||
|
id: orderId,
|
||||||
|
infoKey: `fiscal_info_${id}`,
|
||||||
|
storageKey: `fiscal_storage_${id}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const resData = await res.json();
|
||||||
|
|
||||||
|
let info = {};
|
||||||
|
try {
|
||||||
|
info = JSON.parse(resData?.data?.order?.info?.value || '{}');
|
||||||
|
} catch (e) { }
|
||||||
|
|
||||||
|
let storage = {};
|
||||||
|
try {
|
||||||
|
storage = JSON.parse(resData?.data?.order?.storage?.value || '{}');
|
||||||
|
} catch (e) { }
|
||||||
|
|
||||||
|
return { id, info, storage };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
setReceipts(detailed);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch fiscal data:', e);
|
||||||
|
setError(translate('hutko.failed_to_load_receipts') + e.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [orderId, translate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFiscalData();
|
||||||
|
}, [fetchFiscalData]);
|
||||||
|
const logoSrc = "data:image/svg+xml,%3Csvg width='120' height='30' viewBox='188 423 624 154' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M747.38 473.832C762.386 473.832 774.926 478.66 784.944 488.305C794.962 497.957 800 509.817 800 523.877C800 537.938 794.962 549.594 784.944 559.246C774.876 568.897 762.386 573.72 747.38 573.72C732.374 573.72 719.898 568.947 709.923 559.344C699.955 549.742 694.967 537.938 694.967 523.871C694.967 509.803 700.005 498.104 710.023 488.403C720.095 478.713 732.504 473.835 747.38 473.824V473.832ZM474.833 530.079C474.833 535.826 476.46 540.445 479.713 543.992C482.966 547.531 487.373 549.332 492.984 549.333C499.491 549.333 504.687 547.433 508.621 543.634C512.555 539.835 514.547 534.648 514.547 528.032V475.729H540.883V571.51L540.832 571.461H514.705V561.963C507.467 569.407 497.972 573.15 486.27 573.15C474.568 573.15 465.653 569.61 458.781 562.524C451.909 555.438 448.498 546.102 448.498 534.55V475.721H474.833V530.079ZM266.725 535.497L322.589 501.641L256.912 571.921L255.929 543.519L200.008 571.921H200L269.09 426.28L266.725 535.497ZM375.332 485.276C382.57 477.832 392.065 474.089 403.768 474.089C415.47 474.089 424.384 477.629 431.256 484.715C438.128 491.802 441.539 501.138 441.539 512.689V571.517H415.204V517.161C415.204 511.413 413.577 506.794 410.324 503.248C407.071 499.708 402.664 497.907 397.053 497.907C390.546 497.907 385.35 499.806 381.416 503.605C377.482 507.404 375.49 512.591 375.49 519.208V571.51H349.154V475.729L349.205 452.648H375.332V485.276ZM588.437 475.729H611.991V498.88H588.443V537.326C588.443 541.531 589.44 544.616 591.482 546.459C593.532 548.31 596.836 549.284 601.407 549.284H611.991V571.51H599.508C574.434 571.51 561.943 560.576 561.943 538.658V498.929H547.833V475.729H561.943V452.647H588.437V475.729ZM645.649 518.639H645.707L678.126 475.729H708.238L670.674 523.315L709.75 571.461H676.75L645.649 532.09V571.461H618.941V475.729H645.649V518.639ZM747.38 497.389C739.877 497.389 733.635 499.905 728.598 504.938C723.567 509.971 721.044 516.279 721.044 523.877C721.044 531.475 723.517 537.994 728.39 543.027C733.27 548.059 739.404 550.569 746.807 550.569C754.209 550.569 760.666 548.003 765.904 542.921C771.1 537.84 773.722 531.475 773.722 523.877C773.722 516.279 771.207 509.964 766.169 504.938H766.162C761.132 499.905 754.883 497.389 747.38 497.389Z' fill='%23E40B2D'/%3E%3C/svg%3E";
|
||||||
|
|
||||||
|
return (
|
||||||
|
|
||||||
|
<s-admin-block heading={translate('hutko.fiscal_receipts_title')}>
|
||||||
|
<s-stack gap="base">
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<s-banner tone="critical">{error}</s-banner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && !error && (
|
||||||
|
<s-box padding="base">
|
||||||
|
<s-text>{translate('hutko.loading')}</s-text>
|
||||||
|
</s-box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && receipts.length === 0 && !error && (
|
||||||
|
<s-banner tone="info">{translate('hutko.no_receipts')}</s-banner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && receipts.length > 0 && receipts.map((receipt) => (
|
||||||
|
<s-stack key={receipt.id} gap="base">
|
||||||
|
<s-stack direction="inline" justifyContent="space-between" block-align="center">
|
||||||
|
|
||||||
|
{receipt.info.display_datetime ? (
|
||||||
|
<>
|
||||||
|
<s-chip font-weight="bold" ><s-icon slot="graphic" type="calendar-time" size="small"></s-icon> {receipt.info.display_datetime}</s-chip>
|
||||||
|
{receipt.info.amount && (
|
||||||
|
<s-chip><s-icon slot="graphic" type="money" size="small"></s-icon> {receipt.info.amount} {translate('hutko.currency')}</s-chip>
|
||||||
|
)}
|
||||||
|
<s-button commandFor="customer-menu" icon="menu-horizontal"></s-button>
|
||||||
|
|
||||||
|
<s-menu id="customer-menu" accessibilityLabel={translate('hutko.customer_actions')}>
|
||||||
|
{receipt.storage.html && (
|
||||||
|
<s-button icon="view"
|
||||||
|
onClick={() =>
|
||||||
|
openBase64AsBlob(receipt.storage.html, 'text/html')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{translate('hutko.view_receipt')}
|
||||||
|
</s-button>
|
||||||
|
)}
|
||||||
|
{receipt.storage.xml && (
|
||||||
|
<s-button icon="code"
|
||||||
|
onClick={() =>
|
||||||
|
downloadBase64AsFile(
|
||||||
|
receipt.storage.xml,
|
||||||
|
`fiscal_receipt_${receipt.id}.xml`,
|
||||||
|
'application/xml'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{translate('hutko.download_xml')}
|
||||||
|
</s-button>
|
||||||
|
)}
|
||||||
|
{receipt.info.links && (
|
||||||
|
<>
|
||||||
|
{receipt.info.links.tax_service && (
|
||||||
|
<s-button href={receipt.info.links.tax_service} target="_blank" icon="external">
|
||||||
|
{translate('hutko.tax_service')}
|
||||||
|
</s-button>
|
||||||
|
)}
|
||||||
|
{receipt.info.links.external_provider && (
|
||||||
|
<s-button href={receipt.info.links.external_provider} target="_blank" icon="external">
|
||||||
|
{translate('hutko.provider')}
|
||||||
|
</s-button>
|
||||||
|
)}
|
||||||
|
{receipt.info.links.pdf && (
|
||||||
|
<s-button href={receipt.info.links.pdf} target="_blank" icon="external">
|
||||||
|
{translate('hutko.pdf')}
|
||||||
|
</s-button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</s-menu>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<s-text>{translate('hutko.waiting_for_fiscalisation', { id: receipt.id })}</s-text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</s-stack>
|
||||||
|
|
||||||
|
<s-divider />
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</s-stack>
|
||||||
|
|
||||||
|
))}
|
||||||
|
<s-stack direction="inline" justifyContent="space-between" alignItems="center"><s-image
|
||||||
|
src={logoSrc}
|
||||||
|
alt="Hutko"
|
||||||
|
sizes="120px"
|
||||||
|
aspectRatio="4/1"
|
||||||
|
inlineSize="auto"
|
||||||
|
objectFit="contain"
|
||||||
|
loading="lazy"
|
||||||
|
></s-image>
|
||||||
|
<s-stack direction="inline" inline-alignment="end">
|
||||||
|
|
||||||
|
<s-button onClick={fetchFiscalData} disabled={loading} icon="refresh"></s-button>
|
||||||
|
|
||||||
|
</s-stack></s-stack>
|
||||||
|
</s-stack>
|
||||||
|
</s-admin-block>
|
||||||
|
|
||||||
|
);
|
||||||
|
}
|
||||||
13
extensions/fiscal-order-block/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"target": "ES2020",
|
||||||
|
"checkJs": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
475
gemini.md
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
# Distributed Fiscal Data Storage in Shopify
|
||||||
|
|
||||||
|
## **Architecture Proposal: Distributed Fiscal Data Storage in Shopify**
|
||||||
|
|
||||||
|
### **1. Core Objective & Rationale (Why this approach?)**
|
||||||
|
|
||||||
|
The goal is to store Ukrainian PRRO fiscal receipts (HTML for display, XML for legal compliance) directly within a Shopify Order’s database. This shifts the legal responsibility of data retention to the merchant's infrastructure.
|
||||||
|
|
||||||
|
Storing multiple raw HTML and XML strings in a single JSON array introduces critical vulnerabilities: strict Shopify payload limits (128 KB per JSON metafield), string-escaping parsing errors, and race conditions if multiple receipts are generated simultaneously.
|
||||||
|
|
||||||
|
To solve this, we are deploying a **distributed log / relational pattern** using unstructured Shopify metafields. By decoupling the index (list of receipts) from the metadata and the heavy storage payloads, we create an append-only architecture that is infinitely scalable per order, mathematically immune to data-loss via race conditions, and completely safe for native Liquid rendering.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### **2. Proposed Data Structure**
|
||||||
|
|
||||||
|
All data will be saved under the namespace `hutko`. For every new receipt, the middleware will write/update three distinct metafields:
|
||||||
|
|
||||||
|
#### **Tier 1: The Index (****`fiscal_receipts`****)**
|
||||||
|
|
||||||
|
A simple JSON array containing only the receipt IDs. This acts as the pointer for App/template to iterate over.
|
||||||
|
|
||||||
|
* **Key:**`fiscal_receipts`
|
||||||
|
* **Type:**`json`
|
||||||
|
* **Value:**`["123456789", "987654321"]`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"metafields": [
|
||||||
|
{
|
||||||
|
"ownerId": "gid://shopify/Order/1234567890",
|
||||||
|
"namespace": "hutko",
|
||||||
|
"key": "fiscal_receipts",
|
||||||
|
"type": "json",
|
||||||
|
"value": "[\"123456789\",\"987654321\"]"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```graphql
|
||||||
|
mutation SaveFiscalIndex($metafields: [MetafieldsSetInput!]!) {
|
||||||
|
metafieldsSet(metafields: $metafields) {
|
||||||
|
metafields {
|
||||||
|
id
|
||||||
|
namespace
|
||||||
|
key
|
||||||
|
value
|
||||||
|
type
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Tier 2: Lightweight Metadata (****`fiscal_info_{receipt_id}`****)**
|
||||||
|
|
||||||
|
Contains the immutable fiscal data and recovery links.
|
||||||
|
|
||||||
|
* **Key Example:**`fiscal_info_rQrCd4DF69nVnKfLM6GhRQvIx`
|
||||||
|
* **Type:**`json`
|
||||||
|
* **Value:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"payment_id": "rQrCd4DF69nVnKfLM6GhRQvIx",
|
||||||
|
"fiscal_date": "20042026",
|
||||||
|
"fiscal_time": "135110",
|
||||||
|
"fiscal_number": "4000024349", //PRRO number
|
||||||
|
"mac": "jwKFGE6HiqxxVJw_PhmfR2C74azcXl0urwD-_qHcoWE", // optional
|
||||||
|
"display_datetime": "20.04.2026 13:51:10",
|
||||||
|
"amount": 1500.50,
|
||||||
|
"links": {
|
||||||
|
"external_provider": "https://...", //checkbox url
|
||||||
|
"tax_service": "https://...", // with mac to comply with tax service qr code format
|
||||||
|
"pdf": "https://...", // optional
|
||||||
|
"png": "https://..." //optional
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
```
|
||||||
|
```graphql
|
||||||
|
mutation SaveFiscalMetadata($metafields: [MetafieldsSetInput!]!) {
|
||||||
|
metafieldsSet(metafields: $metafields) {
|
||||||
|
metafields {
|
||||||
|
id
|
||||||
|
namespace
|
||||||
|
key
|
||||||
|
value
|
||||||
|
type
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"metafields": [
|
||||||
|
{
|
||||||
|
"ownerId": "gid://shopify/Order/1234567890",
|
||||||
|
"namespace": "hutko",
|
||||||
|
"key": "fiscal_info_123456789",
|
||||||
|
"type": "json",
|
||||||
|
"value": "{\"fiscal_date\":\"20042026\",\"fiscal_time\":\"135110\",\"fiscal_number\":\"4000024349\",\"mac\":\"jwKFGE6HiqxxVJw_PhmfR2C74azcXl0urwD-_qHcoWE\",\"display_datetime\":\"20.04.2026 13:51:10\",\"amount\":1500.5,\"links\":{\"external_provider\":\"https://checkbox.in.ua/receipt/123456789\",\"tax_service\":\"https://cabinet.tax.gov.ua/cashregs/check?id=123456789&fn=4000024349&date=20042026&time=135110&sum=1500.50&mac=jwKFGE6HiqxxVJw_PhmfR2C74azcXl0urwD-_qHcoWE\",\"pdf\":\"https://link-to-pdf\",\"png\":\"https://link-to-png\"}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### **Tier 3: Heavy Storage (****`fiscal_storage_{receipt_id}`****)**
|
||||||
|
|
||||||
|
Dedicated solely to the Base64-encoded strings of the legal XML and visual HTML.
|
||||||
|
|
||||||
|
* **Key Example:**`fiscal_storage_123456789`
|
||||||
|
* **Type:**`json`
|
||||||
|
* **Value:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"html": "PHRhYmxlIHN0eWxlPSJ3aWR0...",
|
||||||
|
"xml": "PD94bWwgdmVyc2lvbj0iMS4wI..."
|
||||||
|
}
|
||||||
|
|
||||||
|
```
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"metafields": [
|
||||||
|
{
|
||||||
|
"ownerId": "gid://shopify/Order/1234567890",
|
||||||
|
"namespace": "hutko",
|
||||||
|
"key": "fiscal_storage_123456789",
|
||||||
|
"type": "json",
|
||||||
|
"value": "{\"html\":\"PHRhYmxlIHN0eWxlPSJ3aWR0aDo...\",\"xml\":\"PD94bWwgdmVyc2lvbj0iMS4wI...\"}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```graphql
|
||||||
|
mutation SaveFiscalStorage($metafields: [MetafieldsSetInput!]!) {
|
||||||
|
metafieldsSet(metafields: $metafields) {
|
||||||
|
metafields {
|
||||||
|
id
|
||||||
|
namespace
|
||||||
|
key
|
||||||
|
value
|
||||||
|
type
|
||||||
|
}
|
||||||
|
userErrors {
|
||||||
|
field
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### **3. Key Benefits**
|
||||||
|
|
||||||
|
* **Zero Data Loss (Race-Condition Immunity):** If a race condition occurs during the Read-Modify-Write cycle of the `fiscal_receipts` index, only the *pointer* is overwritten. The heavy data (`fiscal_storage_{id}`) and metadata (`fiscal_info_{id}`) are unstructured and appended directly to the order. They will remain safely in the database as "orphaned" keys, fully recoverable via an API scan or Admin app.
|
||||||
|
* **Bypasses Payload Limits:** Shopify caps JSON metafields at 128 KB. By isolating each receipt into its own dynamically keyed metafield, we eliminate the risk of a single order's metafield array bloating past the limit.
|
||||||
|
* **Immutable Timestamps:** Storing the State Tax Service (ДПС) timestamps as strict strings (`20042026`) prevents browsers, Shopify servers, or Liquid engines from accidentally applying timezone shifts, preserving the cryptographic validity of the receipt.
|
||||||
|
* **Native Liquid Rendering:** Storing the complex XML/HTML as Base64 strings avoids breaking JSON structures with unescaped quotes. Shopify’s Liquid engine can decode and render this directly on the frontend using `{{ heavy_data.html | base64_decode }}`.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
### **4. Risk Points & Caveats to Monitor**
|
||||||
|
|
||||||
|
* **The 64-Character Key Limit:** Shopify enforces a strict 64-character limit on metafield keys.
|
||||||
|
* *Mitigation:* The prefix `fiscal_storage_` uses 15 characters, leaving **49 characters** for receipt ID. If PRRO provider returns an ID less than 49 characters (standard UUIDs are 36, so this is generally safe).
|
||||||
|
* **Read-Modify-Write Requirement:** Shopify GraphQL cannot "push" to a JSON array. To update `fiscal_receipts`, app must query the existing array, append the new ID in memory, and send the combined array back.
|
||||||
|
*
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// utils/fiscalParser.js
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr || dateStr.length !== 8) return dateStr;
|
||||||
|
return `${dateStr.slice(0, 2)}.${dateStr.slice(2, 4)}.${dateStr.slice(4)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (timeStr) => {
|
||||||
|
if (!timeStr || timeStr.length !== 6) return timeStr;
|
||||||
|
return `${timeStr.slice(0, 2)}:${timeStr.slice(2, 4)}:${timeStr.slice(4)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseFiscalXML = (xmlString) => {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(xmlString, "application/xml");
|
||||||
|
|
||||||
|
const getText = (parent, selector) => parent?.querySelector(selector)?.textContent || '';
|
||||||
|
const getElements = (parent, selector) => Array.from(parent?.querySelectorAll(selector) || []);
|
||||||
|
|
||||||
|
const head = doc.querySelector('CHECKHEAD');
|
||||||
|
const totals = doc.querySelector('CHECKTOTAL');
|
||||||
|
|
||||||
|
return {
|
||||||
|
head: {
|
||||||
|
orgName: getText(head, 'ORGNM'),
|
||||||
|
tin: getText(head, 'TIN'),
|
||||||
|
ipn: getText(head, 'IPN'),
|
||||||
|
pointName: getText(head, 'POINTNM'),
|
||||||
|
pointAddress: getText(head, 'POINTADDR'),
|
||||||
|
date: formatDate(getText(head, 'ORDERDATE')),
|
||||||
|
time: formatTime(getText(head, 'ORDERTIME')),
|
||||||
|
orderNum: getText(head, 'ORDERNUM'),
|
||||||
|
cashDesk: getText(head, 'CASHDESKNUM'),
|
||||||
|
cashRegister: getText(head, 'CASHREGISTERNUM'),
|
||||||
|
offline: getText(head, 'OFFLINE') === 'true',
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
sum: getText(totals, 'SUM'),
|
||||||
|
noRndSum: getText(totals, 'NORNDSUM'),
|
||||||
|
},
|
||||||
|
payments: getElements(doc, 'CHECKPAY > ROW').map(row => ({
|
||||||
|
name: getText(row, 'PAYFORMNM'),
|
||||||
|
sum: getText(row, 'SUM'),
|
||||||
|
paysys: getText(row, 'PAYSYS NAME'),
|
||||||
|
acquire: getText(row, 'ACQUIRENM'),
|
||||||
|
cardMask: getText(row, 'EPZDETAILS'),
|
||||||
|
authCode: getText(row, 'AUTHCD')
|
||||||
|
})),
|
||||||
|
taxes: getElements(doc, 'CHECKTAX > ROW').map(row => ({
|
||||||
|
name: getText(row, 'NAME'),
|
||||||
|
letter: getText(row, 'LETTER'),
|
||||||
|
percent: getText(row, 'PRC'),
|
||||||
|
turnover: getText(row, 'TURNOVER'),
|
||||||
|
sum: getText(row, 'SUM')
|
||||||
|
})),
|
||||||
|
items: getElements(doc, 'CHECKBODY > ROW').map(row => ({
|
||||||
|
code: getText(row, 'CODE'),
|
||||||
|
name: getText(row, 'NAME'),
|
||||||
|
amount: parseFloat(getText(row, 'AMOUNT')).toString(),
|
||||||
|
unit: getText(row, 'UNITNM'),
|
||||||
|
price: getText(row, 'PRICE'),
|
||||||
|
cost: getText(row, 'COST'),
|
||||||
|
letter: getText(row, 'LETTERS')
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generate58mmReceiptHTML = (receipt) => {
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="uk">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Фіскальний Чек</title>
|
||||||
|
<style>
|
||||||
|
@page { margin: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
width: 200px; /* 58mm */
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 10px 5px;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
.center { text-align: center; }
|
||||||
|
.left { text-align: left; }
|
||||||
|
.right { text-align: right; }
|
||||||
|
.bold { font-weight: bold; }
|
||||||
|
.divider { border-bottom: 1px dashed #000; margin: 5px 0; }
|
||||||
|
.flex { display: flex; justify-content: space-between; }
|
||||||
|
.item-name { word-break: break-all; margin-bottom: 2px; }
|
||||||
|
.small { font-size: 10px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="center bold">${receipt.head.orgName}</div>
|
||||||
|
<div class="center">${receipt.head.pointName}</div>
|
||||||
|
<div class="center small">${receipt.head.pointAddress}</div>
|
||||||
|
<div class="center small">ІД: ${receipt.head.tin} / ПН: ${receipt.head.ipn}</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
${receipt.items.map(item => `
|
||||||
|
<div class="item-name">${item.name}</div>
|
||||||
|
<div class="flex">
|
||||||
|
<span>${item.amount} ${item.unit} x ${item.price}</span>
|
||||||
|
<span>${item.cost} ${item.letter}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="flex bold">
|
||||||
|
<span>СУМА:</span>
|
||||||
|
<span>${receipt.totals.sum} грн</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
${receipt.payments.map(pay => `
|
||||||
|
<div class="flex">
|
||||||
|
<span>${pay.name}</span>
|
||||||
|
<span>${pay.sum} грн</span>
|
||||||
|
</div>
|
||||||
|
${pay.cardMask ? `<div class="left small">Картка: ${pay.cardMask}</div>` : ''}
|
||||||
|
${pay.authCode ? `<div class="left small">Код авт: ${pay.authCode}</div>` : ''}
|
||||||
|
${pay.acquire ? `<div class="left small">Еквайр: ${pay.acquire}</div>` : ''}
|
||||||
|
`).join('')}
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
${receipt.taxes.map(tax => `
|
||||||
|
<div class="flex small">
|
||||||
|
<span>${tax.letter} ${tax.name} ${tax.percent}%</span>
|
||||||
|
<span>${tax.sum}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="flex small">
|
||||||
|
<span>ЧЕК № ${receipt.head.orderNum}</span>
|
||||||
|
<span>КАСИР: ${receipt.head.cashDesk}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex small">
|
||||||
|
<span>ФН: ${receipt.head.cashRegister}</span>
|
||||||
|
<span>ЗАК: ${receipt.head.offline ? 'ОФЛАЙН' : 'ОНЛАЙН'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex small">
|
||||||
|
<span>ДАТА: ${receipt.head.date}</span>
|
||||||
|
<span>ЧАС: ${receipt.head.time}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="center bold" style="margin-top: 10px;">ФІСКАЛЬНИЙ ЧЕК</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.onload = function() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// src/Extension.jsx
|
||||||
|
|
||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import {
|
||||||
|
render,
|
||||||
|
BlockStack,
|
||||||
|
Button,
|
||||||
|
Text,
|
||||||
|
Banner
|
||||||
|
} from '@shopify/ui-extensions-preact/admin';
|
||||||
|
import { parseFiscalXML, generate58mmReceiptHTML } from './utils/fiscalParser';
|
||||||
|
|
||||||
|
// Sample XML for testing - replace this with your actual XML data source
|
||||||
|
const SAMPLE_XML = `<CHECK xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||||
|
<CHECKHEAD>
|
||||||
|
<DOCTYPE>0</DOCTYPE>
|
||||||
|
<DOCSUBTYPE>0</DOCSUBTYPE>
|
||||||
|
<UID>136FCB26-4D71-4BBD-9E42-D343DDE0132A</UID>
|
||||||
|
<TIN>43103923</TIN>
|
||||||
|
<IPN>431039210294</IPN>
|
||||||
|
<ORGNM>ТОВ "СПОРТ АКТИВ ПЛЮС"</ORGNM>
|
||||||
|
<POINTNM>ТРЕНАЖЕРНИЙ ЗАЛ</POINTNM>
|
||||||
|
<POINTADDR>м. Київ, Голосіївський район, Голосіївський проспект, буд. 30А</POINTADDR>
|
||||||
|
<ORDERDATE>20042026</ORDERDATE>
|
||||||
|
<ORDERTIME>135110</ORDERTIME>
|
||||||
|
<ORDERNUM>78370</ORDERNUM>
|
||||||
|
<CASHDESKNUM>2</CASHDESKNUM>
|
||||||
|
<CASHREGISTERNUM>4001145600</CASHREGISTERNUM>
|
||||||
|
<OPERTYPENM>Оплата</OPERTYPENM>
|
||||||
|
<VER>1</VER>
|
||||||
|
<OFFLINE>false</OFFLINE>
|
||||||
|
</CHECKHEAD>
|
||||||
|
<CHECKTOTAL>
|
||||||
|
<SUM>35.00</SUM>
|
||||||
|
<RNDSUM>0.00</RNDSUM>
|
||||||
|
<NORNDSUM>35.00</NORNDSUM>
|
||||||
|
</CHECKTOTAL>
|
||||||
|
<CHECKPAY>
|
||||||
|
<ROW ROWNUM="1">
|
||||||
|
<PAYFORMCD>1</PAYFORMCD>
|
||||||
|
<PAYFORMNM>Інтернет еквайринг</PAYFORMNM>
|
||||||
|
<SUM>35.00</SUM>
|
||||||
|
<PAYSYS>
|
||||||
|
<ROW ROWNUM="1">
|
||||||
|
<NAME>Visa</NAME>
|
||||||
|
<ACQUIRENM>ПУМБ</ACQUIRENM>
|
||||||
|
<ACQUIRETRANSID>40316265049</ACQUIRETRANSID>
|
||||||
|
<DEVICEID>1819</DEVICEID>
|
||||||
|
<EPZDETAILS>432609XXXXXX9661</EPZDETAILS>
|
||||||
|
<AUTHCD>981702</AUTHCD>
|
||||||
|
</ROW>
|
||||||
|
</PAYSYS>
|
||||||
|
</ROW>
|
||||||
|
</CHECKPAY>
|
||||||
|
<CHECKTAX>
|
||||||
|
<ROW ROWNUM="1">
|
||||||
|
<TYPE>0</TYPE>
|
||||||
|
<NAME>ПДВ</NAME>
|
||||||
|
<LETTER>А</LETTER>
|
||||||
|
<PRC>20.00</PRC>
|
||||||
|
<TURNOVER>35.00</TURNOVER>
|
||||||
|
<SUM>5.83</SUM>
|
||||||
|
</ROW>
|
||||||
|
</CHECKTAX>
|
||||||
|
<CHECKBODY>
|
||||||
|
<ROW ROWNUM="1">
|
||||||
|
<CODE>101729109</CODE>
|
||||||
|
<NAME>Payment in club: 9</NAME>
|
||||||
|
<UNITNM>од.</UNITNM>
|
||||||
|
<AMOUNT>1.000</AMOUNT>
|
||||||
|
<PRICE>35.00</PRICE>
|
||||||
|
<LETTERS>А</LETTERS>
|
||||||
|
<COST>35.00</COST>
|
||||||
|
</ROW>
|
||||||
|
</CHECKBODY>
|
||||||
|
</CHECK>`;
|
||||||
|
|
||||||
|
function PrintReceiptExtension() {
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const handlePrintClick = () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
// 1. Parse XML
|
||||||
|
const receiptData = parseFiscalXML(SAMPLE_XML);
|
||||||
|
|
||||||
|
// 2. Generate HTML
|
||||||
|
const htmlString = generate58mmReceiptHTML(receiptData);
|
||||||
|
|
||||||
|
// 3. Mount to blob and open for browser print dialog
|
||||||
|
const blob = new Blob([htmlString], { type: 'text/html' });
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
const newWindow = window.open(blobUrl, '_blank');
|
||||||
|
|
||||||
|
if (newWindow) {
|
||||||
|
setTimeout(() => URL.revokeObjectURL(blobUrl), 5000); // Cleanup memory
|
||||||
|
} else {
|
||||||
|
setError('Popup blocker prevented opening the print dialog. Please allow popups.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('XML Parsing or Print Error:', err);
|
||||||
|
setError('Failed to parse or print the fiscal check. Verify the XML format.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BlockStack gap="base">
|
||||||
|
{error && <Banner status="critical">{error}</Banner>}
|
||||||
|
<Text fontWeight="bold">Ukrainian Fiscal Check</Text>
|
||||||
|
<Text>Click the button below to generate and print a 58mm receipt.</Text>
|
||||||
|
|
||||||
|
<Button onClick={handlePrintClick}>
|
||||||
|
Print Fiscal Check
|
||||||
|
</Button>
|
||||||
|
</BlockStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure the target string matches the `target` in your shopify.extension.toml
|
||||||
|
render('Admin::Product::Details::Render', () => <PrintReceiptExtension />);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
143
handover.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Shopify UI Extensions Handover & Migration Guide
|
||||||
|
|
||||||
|
This guide is prepared to facilitate a smooth transfer of the newly implemented **Shopify UI Extensions** (both the Admin Order Details block and the Customer Account Order Status block) from this development environment to the original developer's production repository.
|
||||||
|
|
||||||
|
Since the original app previously used an **iframe-only interface**, adding native UI extensions introduces modern Shopify CLI workflows. This document details all necessary steps, file checklists, and configuration adjustments required for a successful migration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Comprehensive Checklist of Files to Transfer
|
||||||
|
|
||||||
|
The original developer will need the following directories and files. These represent the entire standalone codebase for the new UI extensions:
|
||||||
|
|
||||||
|
### Core Extension Folders
|
||||||
|
|
||||||
|
1. **Admin Order Details Block**: [fiscal-order-block/](file:///home/dev/hutko_shopify_proxy/node/extensions/fiscal-order-block)
|
||||||
|
- [shopify.extension.toml](file:///home/dev/hutko_shopify_proxy/node/extensions/fiscal-order-block/shopify.extension.toml) — *Configuration, targeting, capabilities, and settings.*
|
||||||
|
- [src/BlockExtension.jsx](file:///home/dev/hutko_shopify_proxy/node/extensions/fiscal-order-block/src/BlockExtension.jsx) — *Preact React-like logic using custom `s-*` elements, state management, and GraphQL fetches.*
|
||||||
|
- [locales/en.default.json](file:///home/dev/hutko_shopify_proxy/node/extensions/fiscal-order-block/locales/en.default.json) — *Default English translations.*
|
||||||
|
- [locales/uk.json](file:///home/dev/hutko_shopify_proxy/node/extensions/fiscal-order-block/locales/uk.json) — *Ukrainian translations.*
|
||||||
|
- [shopify.d.ts](file:///home/dev/hutko_shopify_proxy/node/extensions/fiscal-order-block/shopify.d.ts) — *Typing references for Shopify global object.*
|
||||||
|
- [tsconfig.json](file:///home/dev/hutko_shopify_proxy/node/extensions/fiscal-order-block/tsconfig.json) — *TypeScript configuration.*
|
||||||
|
|
||||||
|
2. **Customer Account Order Status Block**: [fiscal-checkout-block/](file:///home/dev/hutko_shopify_proxy/node/extensions/fiscal-checkout-block)
|
||||||
|
- [shopify.extension.toml](file:///home/dev/hutko_shopify_proxy/node/extensions/fiscal-checkout-block/shopify.extension.toml) — *TOML configuration specifying metafield preloads and layout targets.*
|
||||||
|
- [src/OrderStatusExtension.jsx](file:///home/dev/hutko_shopify_proxy/node/extensions/fiscal-checkout-block/src/OrderStatusExtension.jsx) — *Preact React-like implementation matching Customer Account sandbox rules.*
|
||||||
|
- [locales/en.default.json](file:///home/dev/hutko_shopify_proxy/node/extensions/fiscal-checkout-block/locales/en.default.json) — *Default English translations.*
|
||||||
|
- [locales/uk.json](file:///home/dev/hutko_shopify_proxy/node/extensions/fiscal-checkout-block/locales/uk.json) — *Ukrainian translations.*
|
||||||
|
- [shopify.d.ts](file:///home/dev/hutko_shopify_proxy/node/extensions/fiscal-checkout-block/shopify.d.ts) — *Typing references.*
|
||||||
|
- [tsconfig.json](file:///home/dev/hutko_shopify_proxy/node/extensions/fiscal-checkout-block/tsconfig.json) — *TypeScript configuration.*
|
||||||
|
|
||||||
|
### Root Configurations (for comparison and merging)
|
||||||
|
- [package.json](file:///home/dev/hutko_shopify_proxy/node/package.json) — *Required dependencies and CLI scripts.*
|
||||||
|
- [shopify.app.toml](file:///home/dev/hutko_shopify_proxy/node/shopify.app.toml) — *Application config file mapping to Client ID.*
|
||||||
|
- [.graphqlrc.js](file:///home/dev/hutko_shopify_proxy/node/.graphqlrc.js) — *GraphQL schema codegen setup.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Dependencies to Merge (`package.json`)
|
||||||
|
|
||||||
|
To build and compile these extensions, the original developer must merge the following dependencies and configurations into their root [package.json](file:///home/dev/hutko_shopify_proxy/node/package.json):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"shopify": "shopify",
|
||||||
|
"build": "shopify app build",
|
||||||
|
"dev": "shopify app dev",
|
||||||
|
"deploy": "shopify app deploy"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@shopify/ui-extensions": "2025.10.x",
|
||||||
|
"@preact/signals": "^2.3.x",
|
||||||
|
"preact": "^10.29.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@shopify/app": "^3.58.2",
|
||||||
|
"@shopify/cli": "^3.93.2"
|
||||||
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"extensions/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> The modern Shopify CLI uses standard monorepo npm/yarn/pnpm workspaces. Ensuring `"workspaces": ["extensions/*"]` is in the root `package.json` allows Shopify CLI to resolve internal typescript modules and dependency trees correctly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. TOML Configurations & Migration Steps
|
||||||
|
|
||||||
|
### A. Root Application TOML (`shopify.app.toml`)
|
||||||
|
|
||||||
|
In your staging environment, [shopify.app.toml](file:///home/dev/hutko_shopify_proxy/node/shopify.app.toml) lists your developer App settings:
|
||||||
|
- `client_id = "397c02127a580bce65182a72583642d6"` (Your Dev App Client ID)
|
||||||
|
- `handle = "fiscal-proxy-app"`
|
||||||
|
|
||||||
|
#### Action for Staging:
|
||||||
|
You do **not** need to share your [shopify.app.toml](file:///home/dev/hutko_shopify_proxy/node/shopify.app.toml) with the developer if they already have an existing CLI app configuration. If they do **not** have a CLI app configuration (since they were iframe-only), they should create a `shopify.app.toml` and change:
|
||||||
|
- `client_id` ➔ Set to their **Real Production App Client ID** (obtained from the Shopify Partners Dashboard under App > Client ID).
|
||||||
|
- `name` and `handle` ➔ Set to their real App name and handle.
|
||||||
|
- `application_url` ➔ Set to their real production App server domain URL.
|
||||||
|
- `redirect_urls` ➔ Set to their real production OAuth redirect callbacks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### B. Extension Configurations (`shopify.extension.toml`)
|
||||||
|
|
||||||
|
Each extension contains a unique `uid` in its `shopify.extension.toml` that couples it to a specific extension registration in your dev Shopify partners account.
|
||||||
|
- **Staging Order Block UID**: `dfc94970-d01c-b839-2220-ffd1b6ab1c86ac3cce4a`
|
||||||
|
- **Staging Checkout Block UID**: `ca81c72e-43fb-0709-a9a3-373f38c511faf3b68f36`
|
||||||
|
|
||||||
|
#### CRITICAL Handover Modification:
|
||||||
|
Before sending the files, or immediately upon importing, the **original developer MUST delete the `uid` line** from both TOML files:
|
||||||
|
- [fiscal-order-block/shopify.extension.toml](file:///home/dev/hutko_shopify_proxy/node/extensions/fiscal-order-block/shopify.extension.toml)
|
||||||
|
- [fiscal-checkout-block/shopify.extension.toml](file:///home/dev/hutko_shopify_proxy/node/extensions/fiscal-checkout-block/shopify.extension.toml)
|
||||||
|
|
||||||
|
```diff
|
||||||
|
api_version = "2025-10"
|
||||||
|
- uid = "ca81c72e-43fb-0709-a9a3-373f38c511faf3b68f36"
|
||||||
|
[[extensions]]
|
||||||
|
name = "Fiscal Receipt Info"
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> When the `uid` line is absent, running `npm run deploy` on the production app will trigger the Shopify CLI to automatically prompt the developer:
|
||||||
|
> `"Create a new extension?"` or `"Link to an existing extension?"`
|
||||||
|
> The developer will select "Create new" (or link to an existing placeholder in their production dashboard). Shopify CLI will then dynamically register the extension under their production account and **automatically write their own production `uid` back to the TOML files**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architectural Setup for Iframe-Only (Legacy) Apps
|
||||||
|
|
||||||
|
If the developer's original app was entirely iframe-based (e.g. running on a raw Node, Ruby on Rails, or PHP server without Shopify CLI integration):
|
||||||
|
|
||||||
|
1. **Coexistence**: The existing server can continue to serve the iframe dashboard unmodified. The Shopify CLI only serves as a **compilation and build tool** to bundle the React/Preact code and deploy it to Shopify's CDN (where modern checkout/admin extension code is securely hosted and run by Shopify).
|
||||||
|
2. **Setup steps**:
|
||||||
|
- The developer should place their legacy project folder in a subfolder or configure the root directory to act as a Shopify CLI project by running:
|
||||||
|
```bash
|
||||||
|
npm install -D @shopify/cli @shopify/app
|
||||||
|
```
|
||||||
|
- Put `shopify.app.toml` in the project root.
|
||||||
|
- Create an `extensions/` directory and place the `fiscal-order-block/` and `fiscal-checkout-block/` directories inside it.
|
||||||
|
- Run `npx shopify app deploy` to bundle and upload both extensions to the Shopify CDN under their production App listing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Summary Checklist of Next Steps for the Original Developer
|
||||||
|
|
||||||
|
1. Place `fiscal-order-block` and `fiscal-checkout-block` inside their `extensions/` directory.
|
||||||
|
2. Delete `uid` from both `extensions/*/shopify.extension.toml`.
|
||||||
|
3. Add `preact`, `@preact/signals`, and `@shopify/ui-extensions` to their package dependencies.
|
||||||
|
4. Set up `workspaces: ["extensions/*"]` in their root `package.json`.
|
||||||
|
5. Run `npm install` to load all UI packaging and building nodes.
|
||||||
|
6. Authenticate with Shopify Partners CLI:
|
||||||
|
```bash
|
||||||
|
npx shopify login
|
||||||
|
```
|
||||||
|
7. Run the deploy sequence to register and upload the modern pre-compiled blocks directly onto their production listing:
|
||||||
|
```bash
|
||||||
|
npm run deploy
|
||||||
|
```
|
||||||
|
8. Go to their Shopify Partners Dashboard, find their App, click **Extensions**, and verify that both new UI Extensions are successfully registered. Finally, release the new version to live stores!
|
||||||
16
notes.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
our extension should render fiscal data both on customer site and admin site.
|
||||||
|
similarities:
|
||||||
|
|
||||||
|
first read fiscal_receipts for list of id of reciepts, then query for fiscal_info_{receipt_id} and fiscal_storage_{receipt_id}.
|
||||||
|
block should contain list of reciepts with date and amount and action buttons. if html field availiable - should be button to open html as accordeon under reciept line (or what element use polaris for hide-expand)
|
||||||
|
if no html and xml availiable - just show links buttons.
|
||||||
|
links from fiscal_info_ should open in new tab every element should be translatable.
|
||||||
|
check svg folder for icons to integrate. suggest if some icon is missing - we will use awesome icons. All text elements should support localization - that means we should use translation keys for all user visible text. e.g. "Print Fiscal Check" - should be like "{{SHOPFY_APP_NAME}}.print_fiscal_check" and added to locales file.
|
||||||
|
also we need to have print button on the customer site as well. it should be placed near the list of reciepts. that button should call `actions.printCheck({ data: payload })` where payload is html from fiscal_storage_{receipt_id}.
|
||||||
|
|
||||||
|
|
||||||
|
differencies:
|
||||||
|
customer side should query fiscal_receipts and then fiscal_info_ and fiscal_storage_ using ids from fiscal_receipts in 5s, 20s and 40s from page load. if nothing found - do not render block. if only reciept id in fiscal_receipts found - print txt that check id {receipt_id} is waiting for fiscalisation. if fiscal_info_ availiable - we print date and amount and hide id.
|
||||||
|
|
||||||
|
amdin side should not query with intervals (we suppose that all data is in the shopify store already) so query on page load only.
|
||||||
|
admin block always rendered. it should contain button refresh. refresh button press will scan all metafields for fiscal_receipts, wildcards fiscal_storage_* and fiscal_info_*. if there no reciept found, it should print txt "no reciepts found". if reciepts found, it should print list of reciepts. if some id is missing in fiscal_storage_* and fiscal_info_* - it should print txt "fiscalisation is pending" but show reciept id from fiscal_receipts. if id is missing in fiscal_receipts but availiable in others - it should print reciept data (date, amount) and availiavle links.
|
||||||
8633
package-lock.json
generated
Normal file
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "fiscal-proxy-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"shopify": "shopify",
|
||||||
|
"build": "shopify app build",
|
||||||
|
"dev": "shopify app dev",
|
||||||
|
"info": "shopify app info",
|
||||||
|
"generate": "shopify app generate",
|
||||||
|
"deploy": "shopify app deploy"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@shopify/app": "^3.58.2",
|
||||||
|
"@shopify/cli": "^3.93.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@shopify/ui-extensions": "2025.10.x",
|
||||||
|
"@preact/signals": "^2.3.x",
|
||||||
|
"preact": "^10.29.1"
|
||||||
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"extensions/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
24
shopify.app.toml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
client_id = "397c02127a580bce65182a72583642d6"
|
||||||
|
name = "fiscal-proxy-app"
|
||||||
|
handle = "fiscal-proxy-app"
|
||||||
|
application_url = "https://dev.panariga.com/test082023"
|
||||||
|
embedded = true
|
||||||
|
|
||||||
|
[build]
|
||||||
|
automatically_update_urls_on_dev = true
|
||||||
|
include_config_on_deploy = true
|
||||||
|
|
||||||
|
[webhooks]
|
||||||
|
api_version = "2025-07"
|
||||||
|
|
||||||
|
[access_scopes]
|
||||||
|
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
|
||||||
|
scopes = "read_all_orders,read_orders"
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
redirect_urls = [
|
||||||
|
"https://dev.panariga.com/auth/callback"
|
||||||
|
]
|
||||||
|
|
||||||
|
[app_preferences]
|
||||||
|
url = "https://dev.panariga.com/setup"
|
||||||
1
svg/arrow-rotate-back.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M256 64c-56.8 0-107.9 24.7-143.1 64l47.1 0c17.7 0 32 14.3 32 32s-14.3 32-32 32L32 192c-17.7 0-32-14.3-32-32L0 32C0 14.3 14.3 0 32 0S64 14.3 64 32l0 54.7C110.9 33.6 179.5 0 256 0 397.4 0 512 114.6 512 256S397.4 512 256 512c-87 0-163.9-43.4-210.1-109.7-10.1-14.5-6.6-34.4 7.9-44.6s34.4-6.6 44.6 7.9c34.8 49.8 92.4 82.3 157.6 82.3 106 0 192-86 192-192S362 64 256 64z"/></svg>
|
||||||
|
After Width: | Height: | Size: 674 B |
1
svg/arrow-up-right-from-square.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M320 0c-17.7 0-32 14.3-32 32s14.3 32 32 32l82.7 0-201.4 201.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L448 109.3 448 192c0 17.7 14.3 32 32 32s32-14.3 32-32l0-160c0-17.7-14.3-32-32-32L320 0zM80 96C35.8 96 0 131.8 0 176L0 432c0 44.2 35.8 80 80 80l256 0c44.2 0 80-35.8 80-80l0-80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 80c0 8.8-7.2 16-16 16L80 448c-8.8 0-16-7.2-16-16l0-256c0-8.8 7.2-16 16-16l80 0c17.7 0 32-14.3 32-32s-14.3-32-32-32L80 96z"/></svg>
|
||||||
|
After Width: | Height: | Size: 751 B |
1
svg/bank.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M271.9 20.2c-9.8-5.6-21.9-5.6-31.8 0l-224 128c-12.6 7.2-18.8 22-15.1 36S17.5 208 32 208l32 0 0 208 0 0-51.2 38.4C4.7 460.4 0 469.9 0 480 0 497.7 14.3 512 32 512l448 0c17.7 0 32-14.3 32-32 0-10.1-4.7-19.6-12.8-25.6l-51.2-38.4 0-208 32 0c14.5 0 27.2-9.8 30.9-23.8s-2.5-28.8-15.1-36l-224-128zM400 208l0 208-64 0 0-208 64 0zm-112 0l0 208-64 0 0-208 64 0zm-112 0l0 208-64 0 0-208 64 0zM256 96a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>
|
||||||
|
After Width: | Height: | Size: 733 B |
1
svg/cash-register.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M96 0C60.7 0 32 28.7 32 64s28.7 64 64 64l48 0 0 32-57 0c-31.6 0-58.5 23.1-63.3 54.4L1.1 364.1C.4 368.8 0 373.6 0 378.4L0 448c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-69.6c0-4.8-.4-9.6-1.1-14.4L488.2 214.4C483.5 183.1 456.6 160 425 160l-217 0 0-32 48 0c35.3 0 64-28.7 64-64S291.3 0 256 0L96 0zm0 48l160 0c8.8 0 16 7.2 16 16s-7.2 16-16 16L96 80c-8.8 0-16-7.2-16-16s7.2-16 16-16zM64 424c0-13.3 10.7-24 24-24l336 0c13.3 0 24 10.7 24 24s-10.7 24-24 24L88 448c-13.3 0-24-10.7-24-24zm48-160a24 24 0 1 1 0-48 24 24 0 1 1 0 48zm120-24a24 24 0 1 1 -48 0 24 24 0 1 1 48 0zM160 344a24 24 0 1 1 0-48 24 24 0 1 1 0 48zM328 240a24 24 0 1 1 -48 0 24 24 0 1 1 48 0zM256 344a24 24 0 1 1 0-48 24 24 0 1 1 0 48zM424 240a24 24 0 1 1 -48 0 24 24 0 1 1 48 0zM352 344a24 24 0 1 1 0-48 24 24 0 1 1 0 48z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
1
svg/file-arrow-down.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M128 128C128 92.7 156.7 64 192 64L341.5 64C358.5 64 374.8 70.7 386.8 82.7L493.3 189.3C505.3 201.3 512 217.6 512 234.6L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 128zM336 122.5L336 216C336 229.3 346.7 240 360 240L453.5 240L336 122.5zM303 505C312.4 514.4 327.6 514.4 336.9 505L400.9 441C410.3 431.6 410.3 416.4 400.9 407.1C391.5 397.8 376.3 397.7 367 407.1L344 430.1L344 344C344 330.7 333.3 320 320 320C306.7 320 296 330.7 296 344L296 430.1L273 407.1C263.6 397.7 248.4 397.7 239.1 407.1C229.8 416.5 229.7 431.7 239.1 441L303.1 505z"/></svg>
|
||||||
|
After Width: | Height: | Size: 873 B |
1
svg/file-image.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M304 112L192 112C183.2 112 176 119.2 176 128L176 512C176 520.8 183.2 528 192 528L448 528C456.8 528 464 520.8 464 512L464 272L376 272C336.2 272 304 239.8 304 200L304 112zM444.1 224L352 131.9L352 200C352 213.3 362.7 224 376 224L444.1 224zM128 128C128 92.7 156.7 64 192 64L325.5 64C342.5 64 358.8 70.7 370.8 82.7L493.3 205.3C505.3 217.3 512 233.6 512 250.6L512 512C512 547.3 483.3 576 448 576L192 576C156.7 576 128 547.3 128 512L128 128zM387.4 496L252.6 496C236.8 496 224 483.2 224 467.4C224 461 226.1 454.9 230 449.8L297.6 362.9C303 356 311.3 352 320 352C328.7 352 337 356 342.4 362.9L410 449.9C413.9 454.9 416 461.1 416 467.5C416 483.3 403.2 496.1 387.4 496.1zM240 288C257.7 288 272 302.3 272 320C272 337.7 257.7 352 240 352C222.3 352 208 337.7 208 320C208 302.3 222.3 288 240 288z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
1
svg/file-pdf.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><!--! Font Awesome Free 7.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M128 64C92.7 64 64 92.7 64 128L64 512C64 547.3 92.7 576 128 576L208 576L208 464C208 428.7 236.7 400 272 400L448 400L448 234.5C448 217.5 441.3 201.2 429.3 189.2L322.7 82.7C310.7 70.7 294.5 64 277.5 64L128 64zM389.5 240L296 240C282.7 240 272 229.3 272 216L272 122.5L389.5 240zM272 444C261 444 252 453 252 464L252 592C252 603 261 612 272 612C283 612 292 603 292 592L292 564L304 564C337.1 564 364 537.1 364 504C364 470.9 337.1 444 304 444L272 444zM304 524L292 524L292 484L304 484C315 484 324 493 324 504C324 515 315 524 304 524zM400 444C389 444 380 453 380 464L380 592C380 603 389 612 400 612L432 612C460.7 612 484 588.7 484 560L484 496C484 467.3 460.7 444 432 444L400 444zM420 572L420 484L432 484C438.6 484 444 489.4 444 496L444 560C444 566.6 438.6 572 432 572L420 572zM508 464L508 592C508 603 517 612 528 612C539 612 548 603 548 592L548 548L576 548C587 548 596 539 596 528C596 517 587 508 576 508L548 508L548 484L576 484C587 484 596 475 596 464C596 453 587 444 576 444L528 444C517 444 508 453 508 464z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
658
upgradingUI.md
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
---
|
||||||
|
title: Upgrading to 2025-10
|
||||||
|
description: >
|
||||||
|
This guide describes how to upgrade your admin UI extension to API version
|
||||||
|
`2025-10` and adopt web components.
|
||||||
|
source_url:
|
||||||
|
html: 'https://shopify.dev/docs/apps/build/admin/upgrading-to-2025-10'
|
||||||
|
md: 'https://shopify.dev/docs/apps/build/admin/upgrading-to-2025-10.md'
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
For a new admin order UI block (for example, admin.order-details.block.render), you should use Preact + Polaris web components, not React.
|
||||||
|
|
||||||
|
The current guidance in the admin UI extensions docs is:
|
||||||
|
|
||||||
|
Version 2025-07 is the last API version that supports React-based UI components.
|
||||||
|
From 2025-10 and onward, Shopify recommends Preact and web components for admin UI extensions. See:
|
||||||
|
Upgrading to 2025-10 (admin UI extensions, Preact & web components)
|
||||||
|
Why Preact (web components) over React?
|
||||||
|
From the upgrade guide for admin UI extensions (Upgrading to 2025-10):
|
||||||
|
|
||||||
|
React-based @shopify/ui-extensions-react components are being phased out.
|
||||||
|
“Version 2025-07 is the last API version to support React-based UI components. Web components replace them…”
|
||||||
|
The recommended stack going forward is:
|
||||||
|
preact
|
||||||
|
@preact/signals (optional but nice for reactive state)
|
||||||
|
@shopify/ui-extensions (web components + APIs)
|
||||||
|
New admin UI extensions (including order blocks) are scaffolded and documented with Preact by default.
|
||||||
|
So:
|
||||||
|
|
||||||
|
If you’re starting anything new today (including admin order UI blocks):
|
||||||
|
Use Preact + <s-*> web components.
|
||||||
|
If you already have legacy React-based admin UI extensions:
|
||||||
|
You can stay on an older API version (e.g. 2025-04/2025-07) short-term,
|
||||||
|
But you’ll eventually need to upgrade to 2025-10 and migrate those to Preact + web components.
|
||||||
|
What this looks like for admin.order-details.block.render
|
||||||
|
In React (legacy), an order details block might have looked like this (from the upgrade guide):
|
||||||
|
|
||||||
|
tsx
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
8
|
||||||
|
9
|
||||||
|
10
|
||||||
|
11
|
||||||
|
12
|
||||||
|
13
|
||||||
|
14
|
||||||
|
15
|
||||||
|
16
|
||||||
|
17
|
||||||
|
18
|
||||||
|
19
|
||||||
|
20
|
||||||
|
21
|
||||||
|
22
|
||||||
|
23
|
||||||
|
24
|
||||||
|
// LEGACY – React-based admin block (2025-07 or earlier)
|
||||||
|
import {
|
||||||
|
reactExtension,
|
||||||
|
AdminBlock,
|
||||||
|
BlockStack,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
} from '@shopify/ui-extensions-react/admin';
|
||||||
|
|
||||||
|
export default reactExtension(
|
||||||
|
'admin.order-details.block.render',
|
||||||
|
() => <Extension />,
|
||||||
|
);
|
||||||
|
|
||||||
|
function Extension() {
|
||||||
|
return (
|
||||||
|
<AdminBlock title="Order status">
|
||||||
|
<BlockStack gap>
|
||||||
|
<TextField label="Tracking number" />
|
||||||
|
<Button title="Update" variant="primary" />
|
||||||
|
</BlockStack>
|
||||||
|
</AdminBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
For new work you should instead do something like:
|
||||||
|
|
||||||
|
tsx
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
2
|
||||||
|
3
|
||||||
|
4
|
||||||
|
5
|
||||||
|
6
|
||||||
|
7
|
||||||
|
8
|
||||||
|
9
|
||||||
|
10
|
||||||
|
11
|
||||||
|
12
|
||||||
|
13
|
||||||
|
14
|
||||||
|
15
|
||||||
|
16
|
||||||
|
17
|
||||||
|
18
|
||||||
|
19
|
||||||
|
20
|
||||||
|
import '@shopify/ui-extensions/preact';
|
||||||
|
import {render} from 'preact';
|
||||||
|
|
||||||
|
export default function extension() {
|
||||||
|
render(<Extension />, document.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Extension() {
|
||||||
|
// Data for this target comes through the global `shopify` object
|
||||||
|
const orderId = shopify.data.selected?.[0]?.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<s-admin-block heading="Order summary">
|
||||||
|
<s-stack gap="base">
|
||||||
|
<s-text>Order ID: {orderId}</s-text>
|
||||||
|
<s-button variant="primary">Do something</s-button>
|
||||||
|
</s-stack>
|
||||||
|
</s-admin-block>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
This Preact/web-components example has been validated against the polaris-admin-extensions API for the admin.order-details.block.render target.
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
|
||||||
|
Use import '@shopify/ui-extensions/preact'; and render from preact.
|
||||||
|
The entry export is a function that calls render(<Extension />, document.body).
|
||||||
|
UI is declared with Polaris web components:
|
||||||
|
<s-admin-block> (replaces AdminBlock, uses heading instead of title)
|
||||||
|
<s-stack> (replaces BlockStack)
|
||||||
|
<s-text>, <s-button>, etc.
|
||||||
|
Data comes from the global shopify object, e.g. shopify.data.selected?.[0]?.id for the selected order.
|
||||||
|
When might you still use React?
|
||||||
|
You’d only stick with React if:
|
||||||
|
|
||||||
|
You have existing React-based admin UI extensions already in production.
|
||||||
|
You need a short-term, low-risk fix and don’t want to migrate immediately.
|
||||||
|
You’re okay staying on an older API version (≤ 2025-07) for a while.
|
||||||
|
Even in that case, Shopify’s docs explicitly push you toward:
|
||||||
|
|
||||||
|
Upgrading API version to 2025-10 in shopify.extension.toml.
|
||||||
|
Adjusting your package.json dependencies to use Preact instead of React.
|
||||||
|
See the “Adjust package dependencies” section in Upgrading to 2025-10.
|
||||||
|
Practical recommendation for your admin order block
|
||||||
|
For an admin order UI block (e.g. admin.order-details.block.render):
|
||||||
|
|
||||||
|
Scaffold the extension with the CLI (as recommended):
|
||||||
|
bash
|
||||||
|
Copy
|
||||||
|
1
|
||||||
|
shopify app generate extension --template admin_block --name my-admin-order-block
|
||||||
|
In the generated extension:
|
||||||
|
|
||||||
|
Ensure api_version in shopify.extension.toml is set to 2025-10 (or later).
|
||||||
|
Use Preact + web components in your Extension.jsx/tsx.
|
||||||
|
Only consider React if:
|
||||||
|
|
||||||
|
You’re locked to an older API version for now, and
|
||||||
|
You plan a future migration to Preact/web components.
|
||||||
|
|
||||||
|
|
||||||
|
# Upgrading to 2025-10
|
||||||
|
|
||||||
|
This guide describes how to upgrade your admin UI extension to API version `2025-10` and adopt [web components](https://shopify.dev/docs/api/admin-extensions/latest/web-components) from Polaris, Shopify's unified system for building app interfaces.
|
||||||
|
|
||||||
|
Version 2025-07 is the last API version to support React-based UI components. Web components replace them with native UI elements that offer built-in accessibility, better performance, and consistent styling — so your extension looks and behaves like the rest of the Shopify admin.
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Update API version
|
||||||
|
|
||||||
|
Set the API version to `2025-10` in `shopify.extension.toml` to use web components.
|
||||||
|
|
||||||
|
## shopify.extension.toml
|
||||||
|
|
||||||
|
```toml
|
||||||
|
api_version = "2025-10"
|
||||||
|
|
||||||
|
|
||||||
|
[[extensions]]
|
||||||
|
name = "your-extension"
|
||||||
|
handle = "your-extension"
|
||||||
|
type = "ui_extension"
|
||||||
|
uid = "ab22fe63-a741-cbc6-90c1-fbcf94a84426b9cbbe1f"
|
||||||
|
|
||||||
|
|
||||||
|
# Contents of your existing file...
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Adjust package dependencies
|
||||||
|
|
||||||
|
As of `2025-10`, Shopify recommends Preact for UI extensions. Update the dependencies in your `package.json` file and re-install.
|
||||||
|
|
||||||
|
## New dependencies with Preact
|
||||||
|
|
||||||
|
## package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "^10.10.x",
|
||||||
|
"@preact/signals": "^2.3.x",
|
||||||
|
"@shopify/ui-extensions": "2025.10.x"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Previous dependencies with React
|
||||||
|
|
||||||
|
## package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.0.0",
|
||||||
|
"@shopify/ui-extensions": "2025.4.x",
|
||||||
|
"@shopify/ui-extensions-react": "2025.4.x",
|
||||||
|
"react-reconciler": "0.29.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Previous dependencies with JavaScript
|
||||||
|
|
||||||
|
## package.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@shopify/ui-extensions": "2025.4.x"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## TypeScript configuration
|
||||||
|
|
||||||
|
Get full IntelliSense and auto-complete support by adding a config file for your extension at `extensions/{extension-name}/tsconfig.json`. You don't need to change your app's root `tsconfig.json` file.
|
||||||
|
|
||||||
|
## New tsconfig.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"target": "ES2020",
|
||||||
|
"checkJs": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"esModuleInterop": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Old tsconfig.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["./src"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Upgrade the Shopify CLI
|
||||||
|
|
||||||
|
The new CLI adds support for building `2025-10` extensions.
|
||||||
|
|
||||||
|
The `shopify app dev` command runs your app and also generates a `shopify.d.ts` file in your extension directory, adding support for the new global `shopify` object.
|
||||||
|
|
||||||
|
## Support new global shopify object
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Upgrade to latest version of the CLI
|
||||||
|
npm install -g @shopify/cli
|
||||||
|
|
||||||
|
|
||||||
|
# Run the app to generate the type definition file
|
||||||
|
shopify app dev
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Optional ESLint configuration
|
||||||
|
|
||||||
|
If your app uses ESLint, update your configuration to include the new global `shopify` object.
|
||||||
|
|
||||||
|
## .eslintrc.cjs
|
||||||
|
|
||||||
|
```js
|
||||||
|
module.exports = {
|
||||||
|
globals: {
|
||||||
|
shopify: 'readonly',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Migrate API calls
|
||||||
|
|
||||||
|
Instead of accessing APIs from a callback parameter or React hook, access them from the global `shopify` object. Here's an example of migrating API calls for an admin block.
|
||||||
|
|
||||||
|
## New API calls in Preact
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import '@shopify/ui-extensions/preact';
|
||||||
|
import {render} from 'preact';
|
||||||
|
|
||||||
|
|
||||||
|
export default function extension() {
|
||||||
|
render(<Extension />, document.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Extension() {
|
||||||
|
const productId = shopify.data.selected?.[0]?.id;
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<s-admin-block heading="Product information">
|
||||||
|
<s-text>Product ID: {productId}</s-text>
|
||||||
|
</s-admin-block>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Previous API calls in React
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
reactExtension,
|
||||||
|
useApi,
|
||||||
|
AdminBlock,
|
||||||
|
Text,
|
||||||
|
} from '@shopify/ui-extensions-react/admin';
|
||||||
|
|
||||||
|
|
||||||
|
export default reactExtension(
|
||||||
|
'admin.product-details.block.render',
|
||||||
|
() => <Extension />,
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
function Extension() {
|
||||||
|
const {data} = useApi();
|
||||||
|
const productId = data.selected?.[0]?.id;
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminBlock title="Product information">
|
||||||
|
<Text>Product ID: {productId}</Text>
|
||||||
|
</AdminBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Previous API calls in JavaScript
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {extension, AdminBlock, Text} from '@shopify/ui-extensions/admin';
|
||||||
|
|
||||||
|
|
||||||
|
export default extension(
|
||||||
|
'admin.product-details.block.render',
|
||||||
|
(root, api) => {
|
||||||
|
const productId = api.data.selected?.[0]?.id;
|
||||||
|
|
||||||
|
|
||||||
|
const adminBlock = root.createComponent(
|
||||||
|
AdminBlock,
|
||||||
|
{title: 'Product information'},
|
||||||
|
[root.createComponent(Text, {}, `Product ID: ${productId}`)],
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
root.appendChild(adminBlock);
|
||||||
|
root.mount();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Migrate hooks
|
||||||
|
|
||||||
|
If you were previously using React hooks, import those same hooks from a Preact-specific package. Here's an example of migrating hooks for an admin action.
|
||||||
|
|
||||||
|
## New hooks in Preact
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import '@shopify/ui-extensions/preact';
|
||||||
|
import {render} from 'preact';
|
||||||
|
import {useState} from 'preact/hooks';
|
||||||
|
|
||||||
|
|
||||||
|
export default function extension() {
|
||||||
|
render(<Extension />, document.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Extension() {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const productId = shopify.data.selected?.[0]?.id;
|
||||||
|
await fetch('shopify:admin/api/graphql.json', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `mutation SetMetafield($input: [MetafieldsSetInput!]!) {
|
||||||
|
metafieldsSet(metafields: $input) { metafields { id } }
|
||||||
|
}`,
|
||||||
|
variables: {
|
||||||
|
input: [{
|
||||||
|
ownerId: productId,
|
||||||
|
namespace: 'my-app',
|
||||||
|
key: 'title',
|
||||||
|
type: 'single_line_text_field',
|
||||||
|
value: title,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
shopify.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<s-admin-action heading="Set title">
|
||||||
|
<s-text-field
|
||||||
|
label="Title"
|
||||||
|
value={title}
|
||||||
|
onInput={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
<s-button variant="primary" onClick={handleSubmit}>
|
||||||
|
Save
|
||||||
|
</s-button>
|
||||||
|
</s-admin-action>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Previous hooks in React
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import React, {useState} from 'react';
|
||||||
|
import {
|
||||||
|
reactExtension,
|
||||||
|
useApi,
|
||||||
|
AdminAction,
|
||||||
|
Button,
|
||||||
|
TextField,
|
||||||
|
} from '@shopify/ui-extensions-react/admin';
|
||||||
|
|
||||||
|
|
||||||
|
export default reactExtension(
|
||||||
|
'admin.product-details.action.render',
|
||||||
|
() => <Extension />,
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
function Extension() {
|
||||||
|
const {close, data, query} = useApi();
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
const productId = data.selected?.[0]?.id;
|
||||||
|
await query(
|
||||||
|
`mutation SetMetafield($input: [MetafieldsSetInput!]!) {
|
||||||
|
metafieldsSet(metafields: $input) { metafields { id } }
|
||||||
|
}`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
input: [{
|
||||||
|
ownerId: productId,
|
||||||
|
namespace: 'my-app',
|
||||||
|
key: 'title',
|
||||||
|
type: 'single_line_text_field',
|
||||||
|
value: title,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminAction title="Set title" primaryAction={<Button onPress={handleSubmit}>Save</Button>}>
|
||||||
|
<TextField label="Title" value={title} onChange={setTitle} />
|
||||||
|
</AdminAction>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Migrate to web components
|
||||||
|
|
||||||
|
Web components are exposed as custom HTML elements. Update your React components to custom elements.
|
||||||
|
|
||||||
|
## New components in Preact
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import '@shopify/ui-extensions/preact';
|
||||||
|
import {render} from 'preact';
|
||||||
|
|
||||||
|
|
||||||
|
export default function extension() {
|
||||||
|
render(<Extension />, document.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function Extension() {
|
||||||
|
return (
|
||||||
|
<s-admin-block heading="Order status">
|
||||||
|
<s-stack gap="base">
|
||||||
|
<s-text-field label="Tracking number"></s-text-field>
|
||||||
|
<s-button variant="primary">Update</s-button>
|
||||||
|
</s-stack>
|
||||||
|
</s-admin-block>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Previous components in React
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
reactExtension,
|
||||||
|
AdminBlock,
|
||||||
|
BlockStack,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
} from '@shopify/ui-extensions-react/admin';
|
||||||
|
|
||||||
|
|
||||||
|
export default reactExtension(
|
||||||
|
'admin.order-details.block.render',
|
||||||
|
() => <Extension />,
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
function Extension() {
|
||||||
|
return (
|
||||||
|
<AdminBlock title="Order status">
|
||||||
|
<BlockStack gap>
|
||||||
|
<TextField label="Tracking number" />
|
||||||
|
<Button title="Update" variant="primary" />
|
||||||
|
</BlockStack>
|
||||||
|
</AdminBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Previous components in JavaScript
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
extension,
|
||||||
|
AdminBlock,
|
||||||
|
BlockStack,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
} from '@shopify/ui-extensions/admin';
|
||||||
|
|
||||||
|
|
||||||
|
export default extension(
|
||||||
|
'admin.order-details.block.render',
|
||||||
|
(root, _api) => {
|
||||||
|
root.replaceChildren(
|
||||||
|
root.createComponent(
|
||||||
|
AdminBlock,
|
||||||
|
{title: 'Order status'},
|
||||||
|
[
|
||||||
|
root.createComponent(BlockStack, {gap: true}, [
|
||||||
|
root.createComponent(TextField, {label: 'Tracking number'}),
|
||||||
|
root.createComponent(Button, {
|
||||||
|
title: 'Update',
|
||||||
|
variant: 'primary',
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
***
|
||||||
|
|
||||||
|
## Web components mapping
|
||||||
|
|
||||||
|
The following table maps each legacy React component to its [web component](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components) equivalent.
|
||||||
|
|
||||||
|
| **Legacy component** | **Web component** | **Migration notes** |
|
||||||
|
| - | - | - |
|
||||||
|
| `AdminAction` | [`Admin action`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/settings-and-templates/admin-action) | Use `heading` instead of `title`. Primary and secondary actions are rendered as child elements. |
|
||||||
|
| `AdminBlock` | [`Admin block`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/settings-and-templates/admin-block) | Use `heading` instead of `title`. |
|
||||||
|
| `AdminPrintAction` | [`Admin print action`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/settings-and-templates/admin-print-action) | None |
|
||||||
|
| `Badge` | [`Badge`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/feedback-and-status-indicators/badge) | None |
|
||||||
|
| `Banner` | [`Banner`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/feedback-and-status-indicators/banner) | None |
|
||||||
|
| `BlockStack` | [`Stack`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/layout-and-structure/stack) | Use the stack component with default block direction. |
|
||||||
|
| `InlineStack` | [`Stack`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/layout-and-structure/stack) | Use the stack component with `direction="inline"`. |
|
||||||
|
| `Box` | [`Box`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/layout-and-structure/box) | None |
|
||||||
|
| `Button` | [`Button`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/actions/button) | None |
|
||||||
|
| `Checkbox` | [`Checkbox`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/forms/checkbox) | None |
|
||||||
|
| `ChoiceList` | [`Choice list`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/forms/choice-list) | None |
|
||||||
|
| `ColorPicker` | [`Color picker`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/forms/color-picker) | None |
|
||||||
|
| `CustomerSegmentTemplate` | [Customer Segment Template Extension API](https://shopify.dev/docs/api/admin-extensions/2025-10/target-apis/contextual-apis/customer-segment-template-extension-api) | Replaced by a target API. Return template data from the `admin.customers.segmentation-templates.data` target instead of rendering a component. |
|
||||||
|
| `DateField` | [`Date field`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/forms/date-field) | None |
|
||||||
|
| `DatePicker` | [`Date picker`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/forms/date-picker) | None |
|
||||||
|
| `Divider` | [`Divider`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/layout-and-structure/divider) | None |
|
||||||
|
| `EmailField` | [`Email field`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/forms/email-field) | None |
|
||||||
|
| `Form` | [`Form`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/forms/form) | None |
|
||||||
|
| `FunctionSettings` | [`Function settings`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/forms/function-settings) | None |
|
||||||
|
| `Heading` | [`Heading`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/typography-and-content/heading) | None |
|
||||||
|
| `HeadingGroup` | | Removed. Use heading levels directly. |
|
||||||
|
| `Icon` | [`Icon`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/media-and-visuals/icon) | None |
|
||||||
|
| `Image` | [`Image`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/media-and-visuals/image) | None |
|
||||||
|
| `Link` | [`Link`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/actions/link) | None |
|
||||||
|
| `MoneyField` | [`Money field`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/forms/money-field) | None |
|
||||||
|
| `NumberField` | [`Number field`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/forms/number-field) | None |
|
||||||
|
| `Paragraph` | [`Paragraph`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/typography-and-content/paragraph) | None |
|
||||||
|
| `PasswordField` | [`Password field`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/forms/password-field) | None |
|
||||||
|
| `Pressable` | [`Clickable`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/actions/clickable) | None |
|
||||||
|
| `ProgressIndicator` | [`Spinner`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/feedback-and-status-indicators/spinner) | None |
|
||||||
|
| `Section` | [`Section`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/layout-and-structure/section) | None |
|
||||||
|
| `Select` | [`Select`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/forms/select) | None |
|
||||||
|
| `Text` | [`Text`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/typography-and-content/text) | None |
|
||||||
|
| `TextArea` | [`Text area`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/forms/text-area) | None |
|
||||||
|
| `TextField` | [`Text field`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/forms/text-field) | None |
|
||||||
|
| `UrlField` | [`URL field`](https://shopify.dev/docs/api/admin-extensions/2025-10/web-components/forms/url-field) | None |
|
||||||
|
|
||||||
|
***
|
||||||