Skip to content

Commit 2c77e2a

Browse files
(SP: 3) [Backend] add Nova Poshta shipping foundation + checkout persistence + async label workflow (#364)
1 parent 03c191b commit 2c77e2a

73 files changed

Lines changed: 22273 additions & 485 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

frontend/.env.example

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ NEXT_PUBLIC_SITE_URL=
77

88
# --- Database
99
DATABASE_URL=
10+
DATABASE_URL_LOCAL=
1011

1112
# --- Upstash Redis (REST)
12-
UPSTASH_REDIS_REST_URL=
1313
UPSTASH_REDIS_REST_TOKEN=
14+
UPSTASH_REDIS_REST_URL=
1415

1516
# --- Auth (app)
1617
AUTH_SECRET=
@@ -46,25 +47,73 @@ CLOUDINARY_URL=
4647

4748
# --- Payments (Stripe)
4849
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
49-
PAYMENTS_ENABLED=
5050
# Options: test, live (defaults to test in development, live in production)
5151
STRIPE_MODE=
52+
STRIPE_PAYMENTS_ENABLED=
5253
STRIPE_SECRET_KEY=
5354
STRIPE_WEBHOOK_SECRET=
5455

56+
# --- Payments (Monobank)
57+
# Optional; set explicitly in production for clarity
58+
MONO_API_BASE=
59+
MONO_INVOICE_TIMEOUT_MS=
60+
61+
# Required for Monobank checkout/webhooks
62+
MONO_MERCHANT_TOKEN=
63+
MONO_PUBLIC_KEY=
64+
65+
# Optional webhook/runtime tuning (defaults in code if omitted)
66+
MONO_REFUND_ENABLED=0
67+
MONO_WEBHOOK_CLAIM_TTL_MS=
68+
MONO_WEBHOOK_MODE=
69+
70+
PAYMENTS_ENABLED=
71+
72+
# --- Shipping (Nova Poshta)
73+
# Toggles (optional; defaults are handled in code)
74+
SHOP_SHIPPING_ENABLED=0
75+
SHOP_SHIPPING_NP_ENABLED=0
76+
77+
# Retention (optional; days, used for cleanup/retention policies)
78+
SHOP_SHIPPING_RETENTION_DAYS=
79+
80+
# Required when shipping is enabled (SHOP_SHIPPING_ENABLED=1 and SHOP_SHIPPING_NP_ENABLED=1).
81+
# If shipping is enabled without required NP config, app throws NovaPoshtaConfigError at runtime.
82+
# Optional if code has a default; set explicitly in production for clarity
83+
NP_API_BASE=
84+
NP_API_KEY=
85+
NP_SENDER_WAREHOUSE_REF=
86+
NP_SENDER_CITY_REF=
87+
NP_SENDER_CONTACT_REF=
88+
NP_SENDER_NAME=
89+
NP_SENDER_PHONE=
90+
NP_SENDER_REF=
91+
92+
# Optional tuning (override only if needed; otherwise code defaults apply)
93+
NP_MAX_RETRIES=
94+
NP_RETRY_DELAY_MS=
95+
NP_TIMEOUT_MS=
96+
5597
# --- Admin / Internal ops
5698
ENABLE_ADMIN_API=
5799
INTERNAL_JANITOR_MIN_INTERVAL_SECONDS=
58100
INTERNAL_JANITOR_SECRET=
59101
JANITOR_URL=
60102

103+
# Optional internal/admin runtime secrets & tuning (used by internal endpoints/jobs)
104+
INTERNAL_SECRET=
105+
JANITOR_TIMEOUT_MS=
106+
107+
# Optional instance IDs for webhook multi-instance diagnostics/claiming
108+
STRIPE_WEBHOOK_INSTANCE_ID=
109+
WEBHOOK_INSTANCE_ID=
110+
61111
# --- Quiz
62112
QUIZ_ENCRYPTION_KEY=
63113

64114
# --- Web3Forms (feedback form)
65-
NEXT_PUBLIC_WEB3FORMS_KEY=
66-
67115
GITHUB_SPONSORS_TOKEN=
116+
NEXT_PUBLIC_WEB3FORMS_KEY=
68117

69118
# --- Telegram
70119
TELEGRAM_BOT_TOKEN=
@@ -75,6 +124,13 @@ EMAIL_FROM=
75124
GMAIL_APP_PASSWORD=
76125
GMAIL_USER=
77126

127+
# --- Shop / Internal
128+
# Optional public/base URL used by shop services/links
129+
SHOP_BASE_URL=
130+
131+
# Required for signed shop status tokens (if status endpoint/token flow is enabled)
132+
SHOP_STATUS_TOKEN_SECRET=
133+
78134
# --- Security
79135
CSRF_SECRET=
80136

@@ -107,6 +163,4 @@ TRUST_FORWARDED_HEADERS=0
107163
# emergency switch
108164
RATE_LIMIT_DISABLED=0
109165

110-
GROQ_API_KEY=
111-
112-
NEXT_PUBLIC_WEB3FORMS_KEY=
166+
GROQ_API_KEY=

frontend/app/[locale]/admin/shop/orders/[id]/RefundButton.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import { useId, useState, useTransition } from 'react';
77
type Props = {
88
orderId: string;
99
disabled: boolean;
10+
csrfToken: string;
1011
};
1112

12-
export function RefundButton({ orderId, disabled }: Props) {
13+
export function RefundButton({ orderId, disabled, csrfToken }: Props) {
1314
const router = useRouter();
1415
const t = useTranslations('shop.admin.refund');
1516
const [isPending, startTransition] = useTransition();
@@ -24,7 +25,10 @@ export function RefundButton({ orderId, disabled }: Props) {
2425
res = await fetch(`/api/shop/admin/orders/${orderId}/refund`, {
2526
method: 'POST',
2627
credentials: 'same-origin',
27-
headers: { 'Content-Type': 'application/json' },
28+
headers: {
29+
'Content-Type': 'application/json',
30+
'x-csrf-token': csrfToken,
31+
},
2832
});
2933
} catch (err) {
3034
const msg =
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
'use client';
2+
3+
import { useRouter } from 'next/navigation';
4+
import { useId, useState, useTransition } from 'react';
5+
6+
type ActionName = 'retry_label_creation' | 'mark_shipped' | 'mark_delivered';
7+
8+
type Props = {
9+
orderId: string;
10+
csrfToken: string;
11+
shippingStatus: string | null;
12+
shipmentStatus: string | null;
13+
};
14+
15+
function actionEnabled(args: {
16+
action: ActionName;
17+
shippingStatus: string | null;
18+
shipmentStatus: string | null;
19+
}): boolean {
20+
if (args.action === 'retry_label_creation') {
21+
return (
22+
args.shipmentStatus === 'failed' || args.shipmentStatus === 'needs_attention'
23+
);
24+
}
25+
if (args.action === 'mark_shipped') {
26+
return args.shippingStatus === 'label_created';
27+
}
28+
return args.shippingStatus === 'shipped';
29+
}
30+
31+
export function ShippingActions({
32+
orderId,
33+
csrfToken,
34+
shippingStatus,
35+
shipmentStatus,
36+
}: Props) {
37+
const router = useRouter();
38+
const [isPending, startTransition] = useTransition();
39+
const [error, setError] = useState<string | null>(null);
40+
const errorId = useId();
41+
42+
async function runAction(action: ActionName) {
43+
setError(null);
44+
45+
let res: Response;
46+
try {
47+
res = await fetch(`/api/shop/admin/orders/${orderId}/shipping`, {
48+
method: 'POST',
49+
credentials: 'same-origin',
50+
headers: {
51+
'Content-Type': 'application/json',
52+
'x-csrf-token': csrfToken,
53+
},
54+
body: JSON.stringify({ action }),
55+
});
56+
} catch (err) {
57+
const msg =
58+
err instanceof Error && err.message ? err.message : 'NETWORK_ERROR';
59+
setError(msg);
60+
return;
61+
}
62+
63+
let json: any = null;
64+
try {
65+
json = await res.json();
66+
} catch {
67+
// ignore
68+
}
69+
70+
if (!res.ok) {
71+
setError(json?.code ?? json?.message ?? `HTTP_${res.status}`);
72+
return;
73+
}
74+
75+
startTransition(() => {
76+
router.refresh();
77+
});
78+
}
79+
80+
const retryEnabled = actionEnabled({
81+
action: 'retry_label_creation',
82+
shippingStatus,
83+
shipmentStatus,
84+
});
85+
const shippedEnabled = actionEnabled({
86+
action: 'mark_shipped',
87+
shippingStatus,
88+
shipmentStatus,
89+
});
90+
const deliveredEnabled = actionEnabled({
91+
action: 'mark_delivered',
92+
shippingStatus,
93+
shipmentStatus,
94+
});
95+
96+
return (
97+
<div className="space-y-3">
98+
<div className="flex flex-wrap gap-2">
99+
<button
100+
type="button"
101+
onClick={() => runAction('retry_label_creation')}
102+
disabled={isPending || !retryEnabled}
103+
aria-busy={isPending}
104+
className="border-border text-foreground hover:bg-secondary rounded-md border px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
105+
>
106+
Retry label creation
107+
</button>
108+
109+
<button
110+
type="button"
111+
onClick={() => runAction('mark_shipped')}
112+
disabled={isPending || !shippedEnabled}
113+
aria-busy={isPending}
114+
className="border-border text-foreground hover:bg-secondary rounded-md border px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
115+
>
116+
Mark shipped
117+
</button>
118+
119+
<button
120+
type="button"
121+
onClick={() => runAction('mark_delivered')}
122+
disabled={isPending || !deliveredEnabled}
123+
aria-busy={isPending}
124+
className="border-border text-foreground hover:bg-secondary rounded-md border px-3 py-1.5 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50"
125+
>
126+
Mark delivered
127+
</button>
128+
</div>
129+
130+
{error ? (
131+
<p id={errorId} role="alert" className="text-destructive text-xs">
132+
{error}
133+
</p>
134+
) : null}
135+
</div>
136+
);
137+
}

0 commit comments

Comments
 (0)