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

@@ -1,18 +1,27 @@
{
"name": "Fiscal Order Status Block",
"hutko": {
"fiscal_receipts_title": "hutko Fiscal Receipts",
"fiscal_receipts": "Fiscal Receipts",
"loading": "Loading fiscal receipts...",
"no_receipts": "No fiscal receipts found.",
"refresh": "Refresh",
"loading": "Loading...",
"no_receipts": "No fiscal receipts found for this order.",
"view_receipt": "View Receipt",
"download_xml": "Download XML",
"tax_service": "Tax Service",
"provider": "Provider",
"provider": "Open Receipt",
"copy_link": "Copy link",
"pdf": "PDF",
"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",
"refresh": "Refresh",
"fiscal_number_label": "FN: "
"customer_actions": "Customer actions",
"done": "Done",
"processing": "Processing",
"ready": "Ready",
"transaction_type_purchase": "Purchase",
"transaction_type_reverse": "Refund",
"fn": "FN"
}
}
}

View File

@@ -1,18 +1,27 @@
{
"name": "Блок статусу фіскального замовлення",
"hutko": {
"fiscal_receipts_title": "Фіскальні чеки hutko",
"fiscal_receipts": "Фіскальні чеки",
"loading": "Завантаження фіскальних чеків...",
"no_receipts": "Фіскальних чеків не знайдено.",
"refresh": "Оновити",
"loading": "Завантаження...",
"no_receipts": "Для цього замовлення не знайдено фіскальних чеків.",
"view_receipt": "Переглянути чек",
"download_xml": "Завантажити XML",
"tax_service": "Податкова служба",
"provider": "Провайдер",
"provider": "Відкрити чек",
"copy_link": "Скопіювати посилання",
"pdf": "PDF",
"waiting_for_fiscalisation": "Чек {{id}} — очікує фіскалізації",
"failed_to_load_receipts": "Не вдалося завантажити фіскальні чеки. Будь ласка, спробуйте ще раз.",
"no_order_selected": "Замовлення не вибрано.",
"failed_to_load_receipts": "Не вдалося завантажити фіскальні чеки: ",
"currency": "грн",
"refresh": "Оновити",
"fiscal_number_label": "ФН: "
"customer_actions": "Дії клієнта",
"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
[[extensions.metafields]]
namespace = "hutko"
namespace = "hutko-fiscal"
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 { useState, useEffect, useCallback } from 'preact/hooks';
function openBase64AsBlob(base64String, mimeType) {
try {
const decoded = atob(base64String);
const bytes = new Uint8Array(decoded.length);
for (let i = 0; i < decoded.length; i++) {
bytes[i] = decoded.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
const win = window.open(url, '_blank');
if (win) {
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
return !!win;
} catch (e) {
console.error('Failed to open blob:', e);
return false;
}
}
function downloadBase64AsFile(base64String, filename, mimeType) {
try {
const decoded = atob(base64String);
const bytes = new Uint8Array(decoded.length);
for (let i = 0; i < decoded.length; i++) {
bytes[i] = decoded.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 5000);
} catch (e) {
console.error('Failed to download file:', e);
}
}
async function queryCustomerAccountAPI(query, variables = {}) {
const res = await fetch(
@@ -52,14 +13,19 @@ async function queryCustomerAccountAPI(query, variables = {}) {
body: JSON.stringify({ query, variables }),
}
);
if (!res.ok) throw new Error(`API request failed: ${res.status}`);
return res.json();
if (!res.ok) {
throw new Error(`API request failed: ${res.status} ${res.statusText}`);
}
const data = await res.json();
return data;
}
export default function extension() {
render(<Extension />, document.body);
}
function Extension() {
const translate = shopify.i18n.translate;
// Get order ID from the order status context signal
@@ -68,8 +34,13 @@ function Extension() {
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) => {
if (isManual) {
setPollIndex(0);
}
const fetchFiscalData = useCallback(async () => {
if (!orderId) {
setLoading(false);
return;
@@ -81,11 +52,11 @@ function Extension() {
// Step 1: Read fiscal_receipts from pre-loaded appMetafields (declared in TOML)
let receiptIds = [];
const metafields = shopify.appMetafields?.value;
if (metafields && metafields.length > 0) {
const entry = metafields.find(
(e) =>
String(e.target?.type) === 'order' &&
e.metafield?.namespace === 'hutko' &&
e.metafield?.namespace === 'hutko-fiscal' &&
e.metafield?.key === 'fiscal_receipts',
);
if (entry?.metafield?.value && typeof entry.metafield.value === 'string') {
@@ -100,16 +71,15 @@ function Extension() {
const indexRes = await queryCustomerAccountAPI(
`query getOrderMetafield($id: ID!) {
order(id: $id) {
metafield(namespace: "hutko", key: "fiscal_receipts") { value }
metafield(namespace: "hutko-fiscal", key: "fiscal_receipts") { value }
}
}`,
{ id: orderId },
);
const rawValue = indexRes?.data?.order?.metafield?.value;
try {
receiptIds = JSON.parse(
indexRes?.data?.order?.metafield?.value || '[]'
);
receiptIds = JSON.parse(rawValue || '[]');
} catch (e) { }
}
@@ -119,20 +89,19 @@ function Extension() {
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(
receiptIds.map(async (id) => {
const res = await queryCustomerAccountAPI(
`query getReceiptData($orderId: ID!, $infoKey: String!, $storageKey: String!) {
`query getReceiptData($orderId: ID!, $infoKey: String!) {
order(id: $orderId) {
info: metafield(namespace: "hutko", key: $infoKey) { value }
storage: metafield(namespace: "hutko", key: $storageKey) { value }
info: metafield(namespace: "hutko-fiscal", key: $infoKey) { value }
}
}`,
{
orderId,
infoKey: `fiscal_info_${id}`,
storageKey: `fiscal_storage_${id}`,
},
);
@@ -141,38 +110,42 @@ function Extension() {
info = JSON.parse(res?.data?.order?.info?.value || '{}');
} catch (e) { }
let storage = {};
try {
storage = JSON.parse(res?.data?.order?.storage?.value || '{}');
} catch (e) { }
return { id, info, storage };
return { id, info };
})
);
setReceipts(detailed);
} catch (e) {
console.error('Failed to fetch fiscal data:', e);
setError(translate('hutko.failed_to_load_receipts'));
setError(shopify.i18n.translate('hutko.failed_to_load_receipts'));
} finally {
setLoading(false);
}
}, [orderId, translate]);
}, [orderId]);
useEffect(() => {
fetchFiscalData();
const t1 = setTimeout(fetchFiscalData, 5000);
const t2 = setTimeout(fetchFiscalData, 20000);
const t3 = setTimeout(fetchFiscalData, 40000);
return () => {
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(t3);
};
}, [fetchFiscalData]);
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) {
return (
<s-box padding="base" border="base" borderRadius="base">
@@ -188,7 +161,10 @@ function Extension() {
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;
}
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>
<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) => (
<s-box key={receipt.id} padding="base" border="base" borderRadius="base">
<s-stack gap="base">
<s-stack direction="inline" justifyContent="space-between" alignItems="center">
<s-stack gap="none">
{receipt.info.display_datetime ? (
<>
<s-text type="strong">{receipt.info.display_datetime}</s-text>
{receipt.info.amount && (
<s-text>{receipt.info.amount} {translate('hutko.currency')}</s-text>
)}
{receipt.info.fiscal_number && (
<s-text type="small">{translate('hutko.fiscal_number_label')}{receipt.info.fiscal_number}</s-text>
)}
</>
) : (
<s-text>{translate('hutko.waiting_for_fiscalisation', { id: receipt.id })}</s-text>
{displayReceipts.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" size="small"></s-icon>&nbsp;{receipt.info.display_datetime}</s-chip>
)}
{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>
)}
</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.storage.html && (
<s-button
onClick={() =>
openBase64AsBlob(receipt.storage.html, 'text/html')
}
>
{translate('hutko.view_receipt')}
: receipt.info.transaction_type === 'reverse'
? <s-stack direction="inline" gap="small" alignItems="center">
<s-icon type="return" ></s-icon>
<s-text>{translate('hutko.transaction_type_reverse')}</s-text>
</s-stack>
: receipt.info.transaction_type}
</s-chip>
)}
<s-button commandFor={`customer-menu-${receipt.id}`} >
<s-icon type="menu-horizontal"></s-icon>
</s-button>
)}
{receipt.storage.xml && (
<s-button
onClick={() =>
downloadBase64AsFile(
receipt.storage.xml,
`fiscal_receipt_${receipt.id}.xml`,
'application/xml'
)
}
>
{translate('hutko.download_xml')}
</s-button>
)}
</s-stack>
{receipt.info.links && (
<s-stack direction="inline" gap="base">
{receipt.info.links.tax_service && (
<s-link href={receipt.info.links.tax_service} target="_blank">
{translate('hutko.tax_service')}
</s-link>
)}
{receipt.info.links.external_provider && (
<s-link href={receipt.info.links.external_provider} target="_blank">
{translate('hutko.provider')}
</s-link>
)}
{receipt.info.links.pdf && (
<s-link href={receipt.info.links.pdf} target="_blank">
{translate('hutko.pdf')}
</s-link>
)}
<s-menu id={`customer-menu-${receipt.id}`} accessibilityLabel={translate('hutko.customer_actions')}>
{receipt.info.fiscal_receipt_number && (
<s-button>
<s-stack direction="inline" gap="small" alignItems="center">
<s-icon type="store" tone="info"></s-icon>
<s-text> {translate('hutko.fn')}&nbsp;{receipt.info.fiscal_receipt_number}</s-text>
</s-stack>
</s-button>
)}
{receipt.info.links && (
<>
{receipt.info.links.external_provider && (
<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-box>
<s-divider />
</s-stack>
))}
<s-stack direction="inline" justifyContent="space-between" alignItems="center">
<s-image
src={logoSrc}
alt="Hutko"
alt="hutko"
aspectRatio="4/1"
objectFit="contain"
sizes="120px"
@@ -280,7 +267,7 @@ function Extension() {
></s-image>
<s-stack direction="inline" justifyContent="end">
<s-button
onClick={fetchFiscalData}
onClick={() => { fetchFiscalData(true); }}
disabled={loading}
>
{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",
"hutko": {
"fiscal_receipts_title": "Hutko Fiscal Receipts",
"fiscal_receipts_title": "hutko Fiscal Receipts",
"fiscal_receipts": "Fiscal Receipts",
"refresh": "Refresh",
"loading": "Loading...",
@@ -9,12 +9,19 @@
"view_receipt": "View Receipt",
"download_xml": "Download XML",
"tax_service": "Tax Service",
"provider": "Provider",
"provider": "Open Receipt",
"copy_link": "Copy link",
"pdf": "PDF",
"waiting_for_fiscalisation": "Receipt {{id}} — waiting for fiscalisation",
"no_order_selected": "No order selected.",
"failed_to_load_receipts": "Failed to load fiscal receipts: ",
"currency": "UAH",
"customer_actions": "Customer actions"
"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": "Блок фіскальних чеків",
"hutko": {
"fiscal_receipts_title": "Фіскальні чеки Hutko",
"fiscal_receipts_title": "Фіскальні чеки hutko",
"fiscal_receipts": "Фіскальні чеки",
"refresh": "Оновити",
"loading": "Завантаження...",
@@ -9,12 +9,19 @@
"view_receipt": "Переглянути чек",
"download_xml": "Завантажити XML",
"tax_service": "Податкова служба",
"provider": "Провайдер",
"provider": "Відкрити чек",
"copy_link": "Скопіювати посилання",
"pdf": "PDF",
"waiting_for_fiscalisation": "Чек {{id}} — очікує фіскалізації",
"no_order_selected": "Замовлення не вибрано.",
"failed_to_load_receipts": "Не вдалося завантажити фіскальні чеки: ",
"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 { useState, useEffect, useCallback } from 'preact/hooks';
function openBase64AsBlob(base64String, mimeType) {
try {
const decoded = atob(base64String);
const bytes = new Uint8Array(decoded.length);
for (let i = 0; i < decoded.length; i++) {
bytes[i] = decoded.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
const win = window.open(url, '_blank');
if (win) {
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
return !!win;
} catch (e) {
console.error('Failed to open blob:', e);
return false;
}
}
function downloadBase64AsFile(base64String, filename, mimeType) {
try {
const decoded = atob(base64String);
const bytes = new Uint8Array(decoded.length);
for (let i = 0; i < decoded.length; i++) {
bytes[i] = decoded.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 5000);
} catch (e) {
console.error('Failed to download file:', e);
}
}
export default function extension() {
render(<Extension />, document.body);
@@ -71,7 +32,7 @@ function Extension() {
query: `
query getOrderMetafields($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 getReceiptData($id: ID!, $infoKey: String!, $storageKey: String!) {
order(id: $id) {
info: metafield(namespace: "hutko", key: $infoKey) { value }
storage: metafield(namespace: "hutko", key: $storageKey) { value }
info: metafield(namespace: "hutko-fiscal", key: $infoKey) { value }
storage: metafield(namespace: "hutko-fiscal", key: $storageKey) { value }
}
}
`,
@@ -129,11 +90,11 @@ function Extension() {
setReceipts(detailed);
} catch (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 {
setLoading(false);
}
}, [orderId, translate]);
}, [orderId]);
useEffect(() => {
fetchFiscalData();
@@ -167,52 +128,46 @@ function Extension() {
{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} {translate('hutko.currency')}</s-chip>
{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>
)}
<s-button commandFor="customer-menu" icon="menu-horizontal"></s-button>
<s-menu id="customer-menu" accessibilityLabel={translate('hutko.customer_actions')}>
{receipt.storage.html && (
<s-button icon="view"
onClick={() =>
openBase64AsBlob(receipt.storage.html, 'text/html')
}
{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.view_receipt')}
</s-button>
)}
{receipt.storage.xml && (
<s-button icon="code"
onClick={() =>
downloadBase64AsFile(
receipt.storage.xml,
`fiscal_receipt_${receipt.id}.xml`,
'application/xml'
)
}
>
{translate('hutko.download_xml')}
{translate('hutko.fn')}&nbsp;{receipt.info.fiscal_receipt_number}
</s-button>
)}
{receipt.info.links && (
<>
{receipt.info.links.tax_service && (
<s-button href={receipt.info.links.tax_service} target="_blank" icon="external">
{translate('hutko.tax_service')}
</s-button>
)}
{receipt.info.links.external_provider && (
<s-button href={receipt.info.links.external_provider} target="_blank" icon="external">
{translate('hutko.provider')}
</s-button>
)}
{receipt.info.links.pdf && (
<s-button href={receipt.info.links.pdf} target="_blank" icon="external">
{translate('hutko.pdf')}
</s-button>
)}
</>
)}
</s-menu>
@@ -233,7 +188,7 @@ function Extension() {
))}
<s-stack direction="inline" justifyContent="space-between" alignItems="center"><s-image
src={logoSrc}
alt="Hutko"
alt="hutko"
sizes="120px"
aspectRatio="4/1"
inlineSize="auto"