Skip to content

Commit 1b38291

Browse files
Release v1.0.4 (#368)
* (SP: 3) [Backend] add Nova Poshta shipping foundation + checkout persistence + async label workflow (#364) * (SP: 2) [Frontend] Reduce Vercel variable costs via caching and analytics cleanup (#367) * perf(vercel): cut runtime costs via notification, blog cache, and analytics changes * perf(blog): remove server searchParams usage to preserve ISR * fix(build): align Netlify Node version and remove SpeedInsights import * chore(release): bump version to 1.0.4 --------- Co-authored-by: Liudmyla Sovetovs <milkaegik@gmail.com>
1 parent 27aeabb commit 1b38291

85 files changed

Lines changed: 22447 additions & 633 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.

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -715,3 +715,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
715715

716716
- Improved client-render guards using stable subscription pattern
717717
- Reduced hydration inconsistencies in production
718+
719+
## [1.0.4] - 2026-02-26
720+
721+
### Added
722+
723+
- Shop shipping foundation:
724+
- Nova Poshta delivery support (cities, warehouses, courier)
725+
- Checkout shipping persistence with PII-safe snapshot
726+
- Async shipment label creation workflow
727+
- Admin shipping actions (retry label, mark shipped/delivered)
728+
- Shipping status and tracking in order details
729+
730+
### Changed
731+
732+
- Performance & cost optimization (Vercel):
733+
- Blog ISR enabled (revalidate: 3600)
734+
- Sanity CDN enabled globally
735+
- Cached blog categories via unstable_cache
736+
- Notification polling replaced with visibility-based refresh
737+
- Analytics runs only in production
738+
- Speed Insights removed
739+
740+
### Fixed
741+
742+
- Reduced unnecessary layout revalidation after notification actions
743+
- Improved cache consistency for blog content and categories
744+
745+
### Performance & Infrastructure
746+
747+
- Lower Vercel Function Invocations and CPU usage
748+
- Reduced origin data transfer for blog content
749+
- Improved overall runtime efficiency

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/actions/notifications.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
'use server';
22

33
import { and,desc, eq } from 'drizzle-orm';
4-
import { revalidatePath } from 'next/cache';
54

65
import { db } from '@/db';
76
import { notifications } from '@/db/schema/notifications';
@@ -39,7 +38,6 @@ export async function markAsRead(notificationId: string) {
3938
)
4039
);
4140

42-
revalidatePath('/', 'layout');
4341
return { success: true };
4442
} catch (error) {
4543
console.error('Failed to mark notification as read:', error);
@@ -57,7 +55,6 @@ export async function markAllAsRead() {
5755
.set({ isRead: true })
5856
.where(and(eq(notifications.userId, session.id), eq(notifications.isRead, false)));
5957

60-
revalidatePath('/', 'layout');
6158
return { success: true };
6259
} catch (error) {
6360
console.error('Failed to mark all notifications as read:', error);
@@ -86,7 +83,6 @@ export async function createNotification(data: {
8683
})
8784
.returning();
8885

89-
revalidatePath('/', 'layout');
9086
return result;
9187
} catch (error) {
9288
console.error('Failed to create notification:', error);

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)