first commit

This commit is contained in:
O K
2026-05-26 10:58:49 +03:00
parent c92aee86a2
commit 93ab7e060d
20 changed files with 792 additions and 2240 deletions

View File

@@ -2,17 +2,14 @@
"cache": { "cache": {
"rate-limited-occurrences-send-error-to-bugsnag": { "rate-limited-occurrences-send-error-to-bugsnag": {
"value": [ "value": [
1779040828657, 1779288547675,
1779040839929, 1779288569233
1779040845225,
1779040846291,
1779040846470
], ],
"timestamp": 1779040846470 "timestamp": 1779288569233
}, },
"most-recent-occurrence-warn-on-multiple-versions": { "most-recent-occurrence-warn-on-multiple-versions": {
"value": true, "value": true,
"timestamp": 1779040828354 "timestamp": 1779288547364
} }
} }
} }

2
.gitignore vendored
View File

@@ -23,7 +23,7 @@ extensions/*/dist
.config
# Ignore shopify files created during app dev # Ignore shopify files created during app dev
.shopify/* .shopify/*
.shopify.lock .shopify.lock

View File

@@ -1,825 +0,0 @@
Youve 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 Ill walk through:
Whats “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. Whats “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
Youre 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, Shopifys 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 youre deliberately aliasing React to Preact at build time (which the docs dont 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(). Thats 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" — youre not compiling JSX as Preact here.
A3. Remove the shopify.d.ts override for this module
For 2025-07 + React, you dont 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 A1A3 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.
Youll 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 youre willing to refactor now, youll be aligned with Shopifys 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 (its 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
}
}
Thats 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', { ... }).
Youd 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 youre 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), Ill 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), youre 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 Ill walk through:
Whats 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 youll want for a 202510 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"
Thats 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.
(Ill show what to change after the code.)
b) Using React wrappers + Preact hooks instead of the Preact/webcomponents 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 youll 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 202510 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 backend 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 youd like, you can paste your package.json for this extension and Ill align it exactly with the 202510 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

View File

@@ -1,18 +1,27 @@
{ {
"name": "Fiscal Order Status Block", "name": "Fiscal Order Status Block",
"hutko": { "hutko": {
"fiscal_receipts_title": "hutko Fiscal Receipts",
"fiscal_receipts": "Fiscal Receipts", "fiscal_receipts": "Fiscal Receipts",
"loading": "Loading fiscal receipts...", "refresh": "Refresh",
"no_receipts": "No fiscal receipts found.", "loading": "Loading...",
"no_receipts": "No fiscal receipts found for this order.",
"view_receipt": "View Receipt", "view_receipt": "View Receipt",
"download_xml": "Download XML", "download_xml": "Download XML",
"tax_service": "Tax Service", "tax_service": "Tax Service",
"provider": "Provider", "provider": "Open Receipt",
"copy_link": "Copy link",
"pdf": "PDF", "pdf": "PDF",
"waiting_for_fiscalisation": "Receipt {{id}} — waiting for fiscalisation", "waiting_for_fiscalisation": "Receipt {{id}} — waiting for fiscalisation",
"failed_to_load_receipts": "Failed to load fiscal receipts. Please try again.", "no_order_selected": "No order selected.",
"failed_to_load_receipts": "Failed to load fiscal receipts: ",
"currency": "UAH", "currency": "UAH",
"refresh": "Refresh", "customer_actions": "Customer actions",
"fiscal_number_label": "FN: " "done": "Done",
"processing": "Processing",
"ready": "Ready",
"transaction_type_purchase": "Purchase",
"transaction_type_reverse": "Refund",
"fn": "FN"
} }
} }

View File

@@ -1,18 +1,27 @@
{ {
"name": "Блок статусу фіскального замовлення", "name": "Блок статусу фіскального замовлення",
"hutko": { "hutko": {
"fiscal_receipts_title": "Фіскальні чеки hutko",
"fiscal_receipts": "Фіскальні чеки", "fiscal_receipts": "Фіскальні чеки",
"loading": "Завантаження фіскальних чеків...", "refresh": "Оновити",
"no_receipts": "Фіскальних чеків не знайдено.", "loading": "Завантаження...",
"no_receipts": "Для цього замовлення не знайдено фіскальних чеків.",
"view_receipt": "Переглянути чек", "view_receipt": "Переглянути чек",
"download_xml": "Завантажити XML", "download_xml": "Завантажити XML",
"tax_service": "Податкова служба", "tax_service": "Податкова служба",
"provider": "Провайдер", "provider": "Відкрити чек",
"copy_link": "Скопіювати посилання",
"pdf": "PDF", "pdf": "PDF",
"waiting_for_fiscalisation": "Чек {{id}} — очікує фіскалізації", "waiting_for_fiscalisation": "Чек {{id}} — очікує фіскалізації",
"failed_to_load_receipts": "Не вдалося завантажити фіскальні чеки. Будь ласка, спробуйте ще раз.", "no_order_selected": "Замовлення не вибрано.",
"failed_to_load_receipts": "Не вдалося завантажити фіскальні чеки: ",
"currency": "грн", "currency": "грн",
"refresh": "Оновити", "customer_actions": "Дії клієнта",
"fiscal_number_label": "ФН: " "done": "Готово",
"processing": "Обробляється",
"ready": "Готово",
"transaction_type_purchase": "Продаж",
"transaction_type_reverse": "Повернення",
"fn": "ФН"
} }
} }

View File

@@ -13,5 +13,5 @@ target = "customer-account.order-status.block.render"
network_access = true network_access = true
[[extensions.metafields]] [[extensions.metafields]]
namespace = "hutko" namespace = "hutko-fiscal"
key = "fiscal_receipts" key = "fiscal_receipts"

View File

@@ -0,0 +1,330 @@
import '@shopify/ui-extensions/customer-account';
import { render } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';
async function queryCustomerAccountAPI(query, variables = {}) {
console.log('[HutkoFiscalDebug] queryCustomerAccountAPI call:', { query, variables });
try {
const res = await fetch(
'shopify://customer-account/api/2025-10/graphql.json',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
}
);
console.log('[HutkoFiscalDebug] queryCustomerAccountAPI response status:', res.status, res.statusText);
if (!res.ok) {
throw new Error(`API request failed: ${res.status} ${res.statusText}`);
}
const data = await res.json();
console.log('[HutkoFiscalDebug] queryCustomerAccountAPI response data:', data);
return data;
} catch (error) {
console.error('[HutkoFiscalDebug] queryCustomerAccountAPI exception:', error);
throw error;
}
}
export default function extension() {
render(<Extension />, document.body);
}
const MOCK_RECEIPTS = [
{
id: 9871,
info: {
payment_id: "rNrWsih9Uj06ivn87af9HWiuj",
amount: 2.0,
currency: "UAH",
order_name: "#1082",
hutko_order_id: 8961,
order_time: "2026-05-08 12:40:41.737232",
hutko_merchant_id: 1000142,
order_status: "reversed",
card_number: "535528*9003",
email: "v.paliienko@hutko.org",
fiscal_status: "done",
fiscal_provider_name: "ereceipt",
transaction_type: "purchase",
fiscal_date: "21052026",
fiscal_time: "182702",
fiscal_number: 4001317034,
fiscal_receipt_number: "6914923030",
display_datetime: "21.05.2026 18:27:02",
links: {
external_provider: "https://cabinet.tax.gov.ua/cashregs/check?id=6914923030&fn=4001317034&date=20260521&time=18.27&sm=2.0"
}
}
},
{
id: 9873,
info: {
payment_id: "rNrWsih9Uj06ivn87af9HWiuj",
amount: 2.0,
currency: "UAH",
order_name: "#1082",
hutko_order_id: 8961,
order_time: "2026-05-08 12:40:41.737232",
hutko_merchant_id: 1000142,
order_status: "reversed",
card_number: "535528*9003",
email: "v.paliienko@hutko.org",
fiscal_status: "done",
fiscal_provider_name: "ereceipt",
transaction_type: "reverse",
fiscal_date: "21052026",
fiscal_time: "182823",
fiscal_number: 4001317034,
fiscal_receipt_number: "6914937076",
display_datetime: "21.05.2026 18:28:23",
links: {
external_provider: "https://cabinet.tax.gov.ua/cashregs/check?id=6914937076&fn=4001317034&date=20260521&time=18.28&sm=2.0"
}
}
}
];
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 [pollIndex, setPollIndex] = useState(0);
const fetchFiscalData = useCallback(async (isManual = false) => {
console.log('[HutkoFiscalDebug] fetchFiscalData triggered. isManual:', isManual, 'orderId:', orderId);
if (isManual) {
setPollIndex(0);
}
if (!orderId) {
console.warn('[HutkoFiscalDebug] fetchFiscalData called but orderId is missing/undefined. Loading stopped.');
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;
console.log('[HutkoFiscalDebug] shopify.appMetafields?.value:', metafields);
if (metafields && metafields.length > 0) {
const entry = metafields.find(
(e) =>
e.metafield?.namespace === 'hutko-fiscal' &&
e.metafield?.key === 'fiscal_receipts',
);
console.log('[HutkoFiscalDebug] Found metafield entry for hutko-fiscal.fiscal_receipts:', entry);
if (entry?.metafield?.value && typeof entry.metafield.value === 'string') {
try {
receiptIds = JSON.parse(entry.metafield.value);
console.log('[HutkoFiscalDebug] Parsed receiptIds from appMetafields:', receiptIds);
} catch (e) {
console.error('[HutkoFiscalDebug] Error parsing fiscal_receipts from appMetafields:', e, 'Raw value:', entry.metafield.value);
}
}
}
// Fallback: query via Customer Account API if appMetafields didn't have it
if (receiptIds.length === 0) {
console.log('[HutkoFiscalDebug] receiptIds is empty. Querying Customer Account API fallback...');
const indexRes = await queryCustomerAccountAPI(
`query getOrderMetafield($id: ID!) {
order(id: $id) {
metafield(namespace: "hutko-fiscal", key: "fiscal_receipts") { value }
}
}`,
{ id: orderId },
);
console.log('[HutkoFiscalDebug] Fallback query indexRes:', indexRes);
const rawValue = indexRes?.data?.order?.metafield?.value;
console.log('[HutkoFiscalDebug] Fallback query raw value:', rawValue);
try {
receiptIds = JSON.parse(rawValue || '[]');
console.log('[HutkoFiscalDebug] Parsed receiptIds from fallback query:', receiptIds);
} catch (e) {
console.error('[HutkoFiscalDebug] Error parsing fallback query fiscal_receipts JSON:', e);
}
}
if (receiptIds.length === 0) {
console.warn('[HutkoFiscalDebug] No receipt IDs found after appMetafields and fallback query. Setting empty receipts.');
setReceipts([]);
setLoading(false);
return;
}
// Step 2: Fetch per-receipt info via Customer Account API
console.log('[HutkoFiscalDebug] Fetching detailed info for receipt IDs:', receiptIds);
const detailed = await Promise.all(
receiptIds.map(async (id) => {
console.log(`[HutkoFiscalDebug] Fetching data for receipt ID: ${id}`);
const res = await queryCustomerAccountAPI(
`query getReceiptData($orderId: ID!, $infoKey: String!) {
order(id: $orderId) {
info: metafield(namespace: "hutko-fiscal", key: $infoKey) { value }
}
}`,
{
orderId,
infoKey: `fiscal_info_${id}`,
},
);
console.log(`[HutkoFiscalDebug] Detailed query response for ID ${id}:`, res);
let info = {};
try {
info = JSON.parse(res?.data?.order?.info?.value || '{}');
} catch (e) { }
return { id, info };
})
);
console.log('[HutkoFiscalDebug] All detailed receipt data resolved successfully:', detailed);
setReceipts(detailed);
} catch (e) {
console.error('[HutkoFiscalDebug] Critical error in fetchFiscalData:', e);
setError(shopify.i18n.translate('hutko.failed_to_load_receipts'));
} finally {
setLoading(false);
}
}, [orderId]);
useEffect(() => {
fetchFiscalData();
}, [fetchFiscalData]);
useEffect(() => {
const isWaiting = receipts.some((receipt) => receipt.info.fiscal_status !== 'done');
console.log('[HutkoFiscalDebug] Polling check. receipts count:', receipts.length, 'isWaiting:', isWaiting, 'pollIndex:', pollIndex);
if (!isWaiting) {
if (pollIndex !== 0) {
console.log('[HutkoFiscalDebug] No receipts are waiting. Resetting pollIndex to 0.');
setPollIndex(0);
}
return;
}
const delays = [5000, 15000, 20000];
if (pollIndex < delays.length) {
console.log(`[HutkoFiscalDebug] Setting poll timer. Delay: ${delays[pollIndex]}ms. Current pollIndex: ${pollIndex}`);
const timer = setTimeout(() => {
console.log(`[HutkoFiscalDebug] Poll timer fired. Advancing pollIndex to ${pollIndex + 1} and fetching data.`);
setPollIndex((prev) => prev + 1);
fetchFiscalData();
}, delays[pollIndex]);
return () => clearTimeout(timer);
} else {
console.log('[HutkoFiscalDebug] pollIndex reached max delay threshold. Stopping auto-poll.');
}
}, [fetchFiscalData, receipts, pollIndex]);
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>;
}
// For testing purposes, if no receipts are found, use mock receipts
const displayReceipts = receipts.length === 2 ? MOCK_RECEIPTS : receipts;
if (displayReceipts.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>hutko {translate('hutko.fiscal_receipts')}</s-heading>
{displayReceipts.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.fiscal_status === 'done') || receipt.info.display_datetime ? (
<>
{receipt.info.display_datetime && (
<s-text type="strong">{receipt.info.display_datetime}</s-text>
)}
{receipt.info.amount && (
<s-text>{receipt.info.amount} {receipt.info.currency || translate('hutko.currency')}</s-text>
)}
{receipt.info.fiscal_receipt_number && (
<s-text type="small">#{receipt.info.fiscal_receipt_number}</s-text>
)}
{receipt.info.fiscal_number && !receipt.info.fiscal_receipt_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>
{receipt.info.links && (
<s-stack direction="inline" gap="base">
{receipt.info.links.external_provider && (
<s-link href={receipt.info.links.external_provider} target="_blank">
{translate('hutko.provider')}
</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(true); }}
disabled={loading}
>
{translate('hutko.refresh')}
</s-button>
</s-stack>
</s-stack>
</s-stack></s-box>
);
}

View File

@@ -2,46 +2,7 @@ import '@shopify/ui-extensions/customer-account';
import { render } from 'preact'; import { render } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks'; 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 = {}) { async function queryCustomerAccountAPI(query, variables = {}) {
const res = await fetch( const res = await fetch(
@@ -52,14 +13,19 @@ async function queryCustomerAccountAPI(query, variables = {}) {
body: JSON.stringify({ query, variables }), body: JSON.stringify({ query, variables }),
} }
); );
if (!res.ok) throw new Error(`API request failed: ${res.status}`); if (!res.ok) {
return res.json(); throw new Error(`API request failed: ${res.status} ${res.statusText}`);
}
const data = await res.json();
return data;
} }
export default function extension() { export default function extension() {
render(<Extension />, document.body); render(<Extension />, document.body);
} }
function Extension() { function Extension() {
const translate = shopify.i18n.translate; const translate = shopify.i18n.translate;
// Get order ID from the order status context signal // Get order ID from the order status context signal
@@ -68,8 +34,13 @@ function Extension() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [receipts, setReceipts] = useState([]); const [receipts, setReceipts] = useState([]);
const [pollIndex, setPollIndex] = useState(0);
const fetchFiscalData = useCallback(async (isManual = false) => {
if (isManual) {
setPollIndex(0);
}
const fetchFiscalData = useCallback(async () => {
if (!orderId) { if (!orderId) {
setLoading(false); setLoading(false);
return; return;
@@ -81,11 +52,11 @@ function Extension() {
// Step 1: Read fiscal_receipts from pre-loaded appMetafields (declared in TOML) // Step 1: Read fiscal_receipts from pre-loaded appMetafields (declared in TOML)
let receiptIds = []; let receiptIds = [];
const metafields = shopify.appMetafields?.value; const metafields = shopify.appMetafields?.value;
if (metafields && metafields.length > 0) { if (metafields && metafields.length > 0) {
const entry = metafields.find( const entry = metafields.find(
(e) => (e) =>
String(e.target?.type) === 'order' && e.metafield?.namespace === 'hutko-fiscal' &&
e.metafield?.namespace === 'hutko' &&
e.metafield?.key === 'fiscal_receipts', e.metafield?.key === 'fiscal_receipts',
); );
if (entry?.metafield?.value && typeof entry.metafield.value === 'string') { if (entry?.metafield?.value && typeof entry.metafield.value === 'string') {
@@ -100,16 +71,15 @@ function Extension() {
const indexRes = await queryCustomerAccountAPI( const indexRes = await queryCustomerAccountAPI(
`query getOrderMetafield($id: ID!) { `query getOrderMetafield($id: ID!) {
order(id: $id) { order(id: $id) {
metafield(namespace: "hutko", key: "fiscal_receipts") { value } metafield(namespace: "hutko-fiscal", key: "fiscal_receipts") { value }
} }
}`, }`,
{ id: orderId }, { id: orderId },
); );
const rawValue = indexRes?.data?.order?.metafield?.value;
try { try {
receiptIds = JSON.parse( receiptIds = JSON.parse(rawValue || '[]');
indexRes?.data?.order?.metafield?.value || '[]'
);
} catch (e) { } } catch (e) { }
} }
@@ -119,20 +89,19 @@ function Extension() {
return; return;
} }
// Step 2: Fetch per-receipt info & storage via Customer Account API // Step 2: Fetch per-receipt info via Customer Account API
const detailed = await Promise.all( const detailed = await Promise.all(
receiptIds.map(async (id) => { receiptIds.map(async (id) => {
const res = await queryCustomerAccountAPI( const res = await queryCustomerAccountAPI(
`query getReceiptData($orderId: ID!, $infoKey: String!, $storageKey: String!) { `query getReceiptData($orderId: ID!, $infoKey: String!) {
order(id: $orderId) { order(id: $orderId) {
info: metafield(namespace: "hutko", key: $infoKey) { value } info: metafield(namespace: "hutko-fiscal", key: $infoKey) { value }
storage: metafield(namespace: "hutko", key: $storageKey) { value }
} }
}`, }`,
{ {
orderId, orderId,
infoKey: `fiscal_info_${id}`, infoKey: `fiscal_info_${id}`,
storageKey: `fiscal_storage_${id}`,
}, },
); );
@@ -141,38 +110,42 @@ function Extension() {
info = JSON.parse(res?.data?.order?.info?.value || '{}'); info = JSON.parse(res?.data?.order?.info?.value || '{}');
} catch (e) { } } catch (e) { }
let storage = {}; return { id, info };
try {
storage = JSON.parse(res?.data?.order?.storage?.value || '{}');
} catch (e) { }
return { id, info, storage };
}) })
); );
setReceipts(detailed); setReceipts(detailed);
} catch (e) { } catch (e) {
console.error('Failed to fetch fiscal data:', e); setError(shopify.i18n.translate('hutko.failed_to_load_receipts'));
setError(translate('hutko.failed_to_load_receipts'));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [orderId, translate]); }, [orderId]);
useEffect(() => { useEffect(() => {
fetchFiscalData(); fetchFiscalData();
const t1 = setTimeout(fetchFiscalData, 5000);
const t2 = setTimeout(fetchFiscalData, 20000);
const t3 = setTimeout(fetchFiscalData, 40000);
return () => {
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(t3);
};
}, [fetchFiscalData]); }, [fetchFiscalData]);
useEffect(() => {
const isWaiting = receipts.some((receipt) => receipt.info.fiscal_status !== 'done');
if (!isWaiting) {
if (pollIndex !== 0) {
setPollIndex(0);
}
return;
}
const delays = [5000, 15000, 20000];
if (pollIndex < delays.length) {
const timer = setTimeout(() => {
setPollIndex((prev) => prev + 1);
fetchFiscalData();
}, delays[pollIndex]);
return () => clearTimeout(timer);
}
}, [fetchFiscalData, receipts, pollIndex]);
if (loading) { if (loading) {
return ( return (
<s-box padding="base" border="base" borderRadius="base"> <s-box padding="base" border="base" borderRadius="base">
@@ -188,7 +161,10 @@ function Extension() {
return <s-banner tone="critical">{error}</s-banner>; return <s-banner tone="critical">{error}</s-banner>;
} }
if (receipts.length === 0) { // For testing purposes, append MOCK_RECEIPTS to receipts
const displayReceipts = [...receipts];
if (displayReceipts.length === 0) {
return null; 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"; 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";
@@ -196,82 +172,93 @@ function Extension() {
return (<s-box> return (<s-box>
<s-stack gap="base" padding="base" border="base" borderRadius="base"> <s-stack gap="base" padding="base" border="base" borderRadius="base">
<s-heading>{translate('hutko.fiscal_receipts')}</s-heading> <s-heading>hutko {translate('hutko.fiscal_receipts')}</s-heading>
{receipts.map((receipt) => ( {displayReceipts.map((receipt) => (
<s-box key={receipt.id} padding="base" border="base" borderRadius="base"> <s-stack key={receipt.id} gap="base">
<s-stack gap="base"> <s-stack direction="inline" justifyContent="space-between" block-align="center">
<s-stack direction="inline" justifyContent="space-between" alignItems="center">
<s-stack gap="none"> {receipt.info.display_datetime ? (
{receipt.info.display_datetime ? ( <>
<> {receipt.info.display_datetime && (
<s-text type="strong">{receipt.info.display_datetime}</s-text> <s-chip font-weight="bold" ><s-icon slot="graphic" type="calendar" size="small"></s-icon>&nbsp;{receipt.info.display_datetime}</s-chip>
{receipt.info.amount && ( )}
<s-text>{receipt.info.amount} {translate('hutko.currency')}</s-text> {receipt.info.amount && (
)} <s-chip><s-icon slot="graphic" type="credit-card" size="small"></s-icon>&nbsp;{receipt.info.amount} {receipt.info.currency || translate('hutko.currency')}</s-chip>
{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> {receipt.info.fiscal_status !== 'done' && (
<s-chip>{translate('hutko.processing')}</s-chip>
)}
{receipt.info.transaction_type && (
<s-chip>
{receipt.info.transaction_type === 'purchase'
? <s-stack direction="inline" gap="small" alignItems="center">
<s-icon type="order" ></s-icon>
<s-text>{translate('hutko.transaction_type_purchase')}</s-text>
</s-stack>
<s-stack direction="inline" gap="base"> : receipt.info.transaction_type === 'reverse'
{receipt.storage.html && ( ? <s-stack direction="inline" gap="small" alignItems="center">
<s-button <s-icon type="return" ></s-icon>
onClick={() => <s-text>{translate('hutko.transaction_type_reverse')}</s-text>
openBase64AsBlob(receipt.storage.html, 'text/html') </s-stack>
} : receipt.info.transaction_type}
> </s-chip>
{translate('hutko.view_receipt')} )}
<s-button commandFor={`customer-menu-${receipt.id}`} >
<s-icon type="menu-horizontal"></s-icon>
</s-button> </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-menu id={`customer-menu-${receipt.id}`} accessibilityLabel={translate('hutko.customer_actions')}>
<s-stack direction="inline" gap="base"> {receipt.info.fiscal_receipt_number && (
{receipt.info.links.tax_service && (
<s-link href={receipt.info.links.tax_service} target="_blank"> <s-button>
{translate('hutko.tax_service')} <s-stack direction="inline" gap="small" alignItems="center">
</s-link> <s-icon type="store" tone="info"></s-icon>
)} <s-text> {translate('hutko.fn')}&nbsp;{receipt.info.fiscal_receipt_number}</s-text>
{receipt.info.links.external_provider && ( </s-stack>
<s-link href={receipt.info.links.external_provider} target="_blank"> </s-button>
{translate('hutko.provider')} )}
</s-link>
)} {receipt.info.links && (
{receipt.info.links.pdf && ( <>
<s-link href={receipt.info.links.pdf} target="_blank">
{translate('hutko.pdf')} {receipt.info.links.external_provider && (
</s-link> <s-button href={receipt.info.links.external_provider} target="_blank" >
)} <s-stack direction="inline" gap="small" alignItems="center">
<s-icon type="external" tone="info"></s-icon>
<s-text>{translate('hutko.provider')}</s-text>
</s-stack>
</s-button>
)}
</>
)}
</s-menu>
</>
) : (
<s-stack direction="inline" gap="small" alignItems="center">
<s-icon type="delivery" tone="info"></s-icon>
<s-text>{translate('hutko.waiting_for_fiscalisation', { id: receipt.id })}</s-text>
</s-stack> </s-stack>
)} )}
</s-stack> </s-stack>
</s-box>
<s-divider />
</s-stack>
))} ))}
<s-stack direction="inline" justifyContent="space-between" alignItems="center"> <s-stack direction="inline" justifyContent="space-between" alignItems="center">
<s-image <s-image
src={logoSrc} src={logoSrc}
alt="Hutko" alt="hutko"
aspectRatio="4/1" aspectRatio="4/1"
objectFit="contain" objectFit="contain"
sizes="120px" sizes="120px"
@@ -280,7 +267,7 @@ function Extension() {
></s-image> ></s-image>
<s-stack direction="inline" justifyContent="end"> <s-stack direction="inline" justifyContent="end">
<s-button <s-button
onClick={fetchFiscalData} onClick={() => { fetchFiscalData(true); }}
disabled={loading} disabled={loading}
> >
{translate('hutko.refresh')} {translate('hutko.refresh')}
@@ -291,3 +278,8 @@ function Extension() {
); );
} }
/**ReducedIconTypes
The subset of icon types available in checkout and customer account surfaces. This is a narrowed set from the full Shopify icon library, containing only the icons supported in these contexts.
'reset' | 'map' | 'menu' | 'search' | 'circle' | 'filter' | 'image' | 'alert-circle' | 'alert-triangle-filled' | 'alert-triangle' | 'arrow-down' | 'arrow-left' | 'arrow-right' | 'arrow-up-right' | 'arrow-up' | 'bag' | 'bullet' | 'calendar' | 'camera' | 'caret-down' | 'cart' | 'cash-dollar' | 'categories' | 'check-circle' | 'check-circle-filled' | 'check' | 'chevron-down' | 'chevron-left' | 'chevron-right' | 'chevron-up' | 'clipboard' | 'clock' | 'credit-card' | 'delete' | 'delivered' | 'delivery' | 'disabled' | 'discount' | 'edit' | 'email' | 'empty' | 'external' | 'geolocation' | 'gift-card' | 'globe' | 'grid' | 'info-filled' | 'info' | 'list-bulleted' | 'location' | 'lock' | 'menu-horizontal' | 'menu-vertical' | 'minus' | 'mobile' | 'note' | 'order' | 'organization' | 'plus' | 'profile' | 'question-circle-filled' | 'question-circle' | 'reorder' | 'return' | 'savings' | 'settings' | 'star-filled' | 'star-half' | 'star' | 'store' | 'truck' | 'upload' | 'x-circle-filled' | 'x-circle' | 'x' */

View File

@@ -1,7 +1,7 @@
{ {
"name": "Fiscal Order Block", "name": "Fiscal Order Block",
"hutko": { "hutko": {
"fiscal_receipts_title": "Hutko Fiscal Receipts", "fiscal_receipts_title": "hutko Fiscal Receipts",
"fiscal_receipts": "Fiscal Receipts", "fiscal_receipts": "Fiscal Receipts",
"refresh": "Refresh", "refresh": "Refresh",
"loading": "Loading...", "loading": "Loading...",
@@ -9,12 +9,19 @@
"view_receipt": "View Receipt", "view_receipt": "View Receipt",
"download_xml": "Download XML", "download_xml": "Download XML",
"tax_service": "Tax Service", "tax_service": "Tax Service",
"provider": "Provider", "provider": "Open Receipt",
"copy_link": "Copy link",
"pdf": "PDF", "pdf": "PDF",
"waiting_for_fiscalisation": "Receipt {{id}} — waiting for fiscalisation", "waiting_for_fiscalisation": "Receipt {{id}} — waiting for fiscalisation",
"no_order_selected": "No order selected.", "no_order_selected": "No order selected.",
"failed_to_load_receipts": "Failed to load fiscal receipts: ", "failed_to_load_receipts": "Failed to load fiscal receipts: ",
"currency": "UAH", "currency": "UAH",
"customer_actions": "Customer actions" "customer_actions": "Customer actions",
"done": "Done",
"processing": "Processing",
"ready": "Ready",
"transaction_type_purchase": "Purchase",
"transaction_type_reverse": "Refund",
"fn": "FN"
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "Блок фіскальних чеків", "name": "Блок фіскальних чеків",
"hutko": { "hutko": {
"fiscal_receipts_title": "Фіскальні чеки Hutko", "fiscal_receipts_title": "Фіскальні чеки hutko",
"fiscal_receipts": "Фіскальні чеки", "fiscal_receipts": "Фіскальні чеки",
"refresh": "Оновити", "refresh": "Оновити",
"loading": "Завантаження...", "loading": "Завантаження...",
@@ -9,12 +9,19 @@
"view_receipt": "Переглянути чек", "view_receipt": "Переглянути чек",
"download_xml": "Завантажити XML", "download_xml": "Завантажити XML",
"tax_service": "Податкова служба", "tax_service": "Податкова служба",
"provider": "Провайдер", "provider": "Відкрити чек",
"copy_link": "Скопіювати посилання",
"pdf": "PDF", "pdf": "PDF",
"waiting_for_fiscalisation": "Чек {{id}} — очікує фіскалізації", "waiting_for_fiscalisation": "Чек {{id}} — очікує фіскалізації",
"no_order_selected": "Замовлення не вибрано.", "no_order_selected": "Замовлення не вибрано.",
"failed_to_load_receipts": "Не вдалося завантажити фіскальні чеки: ", "failed_to_load_receipts": "Не вдалося завантажити фіскальні чеки: ",
"currency": "грн", "currency": "грн",
"customer_actions": "Дії клієнта" "customer_actions": "Дії клієнта",
"done": "Готово",
"processing": "Обробляється",
"ready": "Готово",
"transaction_type_purchase": "Продаж",
"transaction_type_reverse": "Повернення",
"fn": "ФН"
} }
} }

View File

@@ -0,0 +1,207 @@
import '@shopify/ui-extensions/preact';
import { render } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks';
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-fiscal", 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-fiscal", key: $infoKey) { value }
storage: metafield(namespace: "hutko-fiscal", 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 ? (
<>
{receipt.info.display_datetime && (
<s-chip font-weight="bold" ><s-icon slot="graphic" type="calendar-time" size="small"></s-icon>&nbsp;{receipt.info.display_datetime}</s-chip>
)}
{receipt.info.amount && (
<s-chip><s-icon slot="graphic" type="money" size="small"></s-icon>&nbsp;{receipt.info.amount} {receipt.info.currency || translate('hutko.currency')}</s-chip>
)}
{receipt.info.fiscal_status !== 'done' && (
<s-chip>{translate('hutko.processing')}</s-chip>
)}
{receipt.info.transaction_type && (
<s-chip>
{receipt.info.transaction_type === 'purchase'
? translate('hutko.transaction_type_purchase')
: receipt.info.transaction_type === 'reverse'
? translate('hutko.transaction_type_reverse')
: receipt.info.transaction_type}
</s-chip>
)}
<s-button commandFor={`customer-menu-${receipt.id}`} icon="menu-horizontal"></s-button>
<s-menu id={`customer-menu-${receipt.id}`} accessibilityLabel={translate('hutko.customer_actions')}>
{receipt.info.fiscal_receipt_number && (
<s-button
icon="store-managed"
>
{translate('hutko.fn')}&nbsp;{receipt.info.fiscal_receipt_number}
</s-button>
)}
{receipt.info.links && (
<>
{receipt.info.links.external_provider && (
<s-button href={receipt.info.links.external_provider} target="_blank" icon="external">
{translate('hutko.provider')}
</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>
);
}

View File

@@ -2,46 +2,7 @@ import '@shopify/ui-extensions/preact';
import { render } from 'preact'; import { render } from 'preact';
import { useState, useEffect, useCallback } from 'preact/hooks'; 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() { export default function extension() {
render(<Extension />, document.body); render(<Extension />, document.body);
@@ -71,7 +32,7 @@ function Extension() {
query: ` query: `
query getOrderMetafields($id: ID!) { query getOrderMetafields($id: ID!) {
order(id: $id) { order(id: $id) {
receipts: metafield(namespace: "hutko", key: "fiscal_receipts") { value } receipts: metafield(namespace: "hutko-fiscal", key: "fiscal_receipts") { value }
} }
} }
`, `,
@@ -98,8 +59,8 @@ function Extension() {
query: ` query: `
query getReceiptData($id: ID!, $infoKey: String!, $storageKey: String!) { query getReceiptData($id: ID!, $infoKey: String!, $storageKey: String!) {
order(id: $id) { order(id: $id) {
info: metafield(namespace: "hutko", key: $infoKey) { value } info: metafield(namespace: "hutko-fiscal", key: $infoKey) { value }
storage: metafield(namespace: "hutko", key: $storageKey) { value } storage: metafield(namespace: "hutko-fiscal", key: $storageKey) { value }
} }
} }
`, `,
@@ -129,11 +90,11 @@ function Extension() {
setReceipts(detailed); setReceipts(detailed);
} catch (e) { } catch (e) {
console.error('Failed to fetch fiscal data:', e); console.error('Failed to fetch fiscal data:', e);
setError(translate('hutko.failed_to_load_receipts') + e.message); setError(shopify.i18n.translate('hutko.failed_to_load_receipts') + e.message);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [orderId, translate]); }, [orderId]);
useEffect(() => { useEffect(() => {
fetchFiscalData(); fetchFiscalData();
@@ -167,52 +128,46 @@ function Extension() {
{receipt.info.display_datetime ? ( {receipt.info.display_datetime ? (
<> <>
<s-chip font-weight="bold" ><s-icon slot="graphic" type="calendar-time" size="small"></s-icon>&nbsp;{receipt.info.display_datetime}</s-chip> {receipt.info.display_datetime && (
{receipt.info.amount && ( <s-chip font-weight="bold" ><s-icon slot="graphic" type="calendar-time" size="small"></s-icon>&nbsp;{receipt.info.display_datetime}</s-chip>
<s-chip><s-icon slot="graphic" type="money" size="small"></s-icon>&nbsp;{receipt.info.amount} {translate('hutko.currency')}</s-chip> )}
{receipt.info.amount && (
<s-chip><s-icon slot="graphic" type="money" size="small"></s-icon>&nbsp;{receipt.info.amount} {receipt.info.currency || 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.info.fiscal_status !== 'done' && (
{receipt.storage.html && ( <s-chip>{translate('hutko.processing')}</s-chip>
<s-button icon="view" )}
onClick={() => {receipt.info.transaction_type && (
openBase64AsBlob(receipt.storage.html, 'text/html') <s-chip>
} {receipt.info.transaction_type === 'purchase'
? translate('hutko.transaction_type_purchase')
: receipt.info.transaction_type === 'reverse'
? translate('hutko.transaction_type_reverse')
: receipt.info.transaction_type}
</s-chip>
)}
<s-button commandFor={`customer-menu-${receipt.id}`} icon="menu-horizontal"></s-button>
<s-menu id={`customer-menu-${receipt.id}`} accessibilityLabel={translate('hutko.customer_actions')}>
{receipt.info.fiscal_receipt_number && (
<s-button
icon="store-managed"
> >
{translate('hutko.view_receipt')} {translate('hutko.fn')}&nbsp;{receipt.info.fiscal_receipt_number}
</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> </s-button>
)} )}
{receipt.info.links && ( {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 && ( {receipt.info.links.external_provider && (
<s-button href={receipt.info.links.external_provider} target="_blank" icon="external"> <s-button href={receipt.info.links.external_provider} target="_blank" icon="external">
{translate('hutko.provider')} {translate('hutko.provider')}
</s-button> </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-menu>
@@ -233,7 +188,7 @@ function Extension() {
))} ))}
<s-stack direction="inline" justifyContent="space-between" alignItems="center"><s-image <s-stack direction="inline" justifyContent="space-between" alignItems="center"><s-image
src={logoSrc} src={logoSrc}
alt="Hutko" alt="hutko"
sizes="120px" sizes="120px"
aspectRatio="4/1" aspectRatio="4/1"
inlineSize="auto" inlineSize="auto"

475
gemini.md
View File

@@ -1,475 +0,0 @@
# 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 Orders 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. Shopifys 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 />);

View File

@@ -73,7 +73,7 @@ To build and compile these extensions, the original developer must merge the fol
In your staging environment, [shopify.app.toml](file:///home/dev/hutko_shopify_proxy/node/shopify.app.toml) lists your developer App settings: 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) - `client_id = "397c02127a580bce65182a72583642d6"` (Your Dev App Client ID)
- `handle = "fiscal-proxy-app"` - `handle = "hutko-fiscal-ui-test"`
#### Action for Staging: #### 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: 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:

View File

@@ -1,16 +0,0 @@
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.

4
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{ {
"name": "fiscal-proxy-app", "name": "hutko-fiscal-ui-test",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "fiscal-proxy-app", "name": "hutko-fiscal-ui-test",
"version": "1.0.0", "version": "1.0.0",
"workspaces": [ "workspaces": [
"extensions/*" "extensions/*"

View File

@@ -1,5 +1,5 @@
{ {
"name": "fiscal-proxy-app", "name": "hutko-fiscal-ui-test",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"scripts": { "scripts": {

View File

@@ -1,24 +1,22 @@
client_id = "397c02127a580bce65182a72583642d6" # Learn more about configuring your app at https://shopify.dev/docs/apps/tools/cli/configuration
name = "fiscal-proxy-app"
handle = "fiscal-proxy-app" name = "hutko-fiscal-ui-test"
handle = "hutko-fiscal-ui-test"
application_url = "https://dev.panariga.com/test082023" application_url = "https://dev.panariga.com/test082023"
embedded = true embedded = true
client_id = "a75366f971c123d9ddbf5f8d6114b8fd"
[build]
automatically_update_urls_on_dev = true
include_config_on_deploy = true
[webhooks] [webhooks]
api_version = "2025-07" api_version = "2026-04"
[access_scopes] [access_scopes]
# Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes # Learn more at https://shopify.dev/docs/apps/tools/cli/configuration#access_scopes
scopes = "read_all_orders,read_orders" scopes = "read_orders,write_orders,customer_read_orders"
optional_scopes = [ ]
use_legacy_install_flow = false
[auth] [auth]
redirect_urls = [ redirect_urls = [ "https://dev.panariga.com/auth/callback" ]
"https://dev.panariga.com/auth/callback"
]
[app_preferences] [app_preferences]
url = "https://dev.panariga.com/setup" url = "https://dev.panariga.com/setup"

15
shopify_groundings.md Normal file
View File

@@ -0,0 +1,15 @@
# Shopify App Scopes Justification
This document outlines the business justification for the requested access scopes, which are required to ensure compliance with Ukrainian fiscal and tax legislation.
## 1. `read_orders` (including access to orders older than 60 days)
**Justification:**
Our application assists merchants in complying with mandatory Ukrainian tax laws, which require the generation, printing, and storage of fiscal receipts for all transactions. According to local regulations, tax audits can be conducted for periods exceeding 3 years. Therefore, merchants must have uninterrupted, ongoing access to historical fiscal receipts for all their past orders. We require the `read_orders` scope (including access to all orders, regardless of the 60-day limit) so that the application can retrieve and display historical fiscal documents for the merchant during tax inspections or upon demand, independent of their current acquiring relationships.
## 2. `write_orders`
**Justification:**
To ensure that fiscal documents are permanently and securely accessible to the merchant, our application stores the generated fiscal receipts directly within the order's metafields. This guarantees that the fiscal data is tied to the order in Shopify and remains available even if the merchant terminates their relationship with a specific payment gateway. Since standard payment gateway integrations do not have the required access levels to write this specific data to order metafields, our application requires the `write_orders` scope to save these fiscal receipts and related metadata to the corresponding orders.
## 3. `customer_read_orders` (Access to Customer Order History)
**Justification:**
Ukrainian legislation also strictly requires that the buyer (customer) receives and has access to their fiscal receipt. Our application displays these fiscal receipts directly to the customer via Customer Account and Checkout UI extensions. The `customer_read_orders` scope is essential to allow the application to read the customer's order history within their personal account, fetch the associated fiscal receipts from the order metafields, and display them securely and conveniently to the buyer.

View File

@@ -1,658 +0,0 @@
---
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 youre 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 youll 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?
Youd 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 dont want to migrate immediately.
Youre okay staying on an older API version (≤ 2025-07) for a while.
Even in that case, Shopifys 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:
Youre 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 |
***