Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -715,3 +715,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

- Improved client-render guards using stable subscription pattern
- Reduced hydration inconsistencies in production

## [1.0.4] - 2026-02-26

### Added

- Shop shipping foundation:
- Nova Poshta delivery support (cities, warehouses, courier)
- Checkout shipping persistence with PII-safe snapshot
- Async shipment label creation workflow
- Admin shipping actions (retry label, mark shipped/delivered)
- Shipping status and tracking in order details

### Changed

- Performance & cost optimization (Vercel):
- Blog ISR enabled (revalidate: 3600)
- Sanity CDN enabled globally
- Cached blog categories via unstable_cache
- Notification polling replaced with visibility-based refresh
- Analytics runs only in production
- Speed Insights removed

### Fixed

- Reduced unnecessary layout revalidation after notification actions
- Improved cache consistency for blog content and categories

### Performance & Infrastructure

- Lower Vercel Function Invocations and CPU usage
- Reduced origin data transfer for blog content
- Improved overall runtime efficiency
68 changes: 61 additions & 7 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ NEXT_PUBLIC_SITE_URL=

# --- Database
DATABASE_URL=
DATABASE_URL_LOCAL=

# --- Upstash Redis (REST)
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
UPSTASH_REDIS_REST_URL=

# --- Auth (app)
AUTH_SECRET=
Expand Down Expand Up @@ -46,25 +47,73 @@ CLOUDINARY_URL=

# --- Payments (Stripe)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
PAYMENTS_ENABLED=
# Options: test, live (defaults to test in development, live in production)
STRIPE_MODE=
STRIPE_PAYMENTS_ENABLED=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=

# --- Payments (Monobank)
# Optional; set explicitly in production for clarity
MONO_API_BASE=
MONO_INVOICE_TIMEOUT_MS=

# Required for Monobank checkout/webhooks
MONO_MERCHANT_TOKEN=
MONO_PUBLIC_KEY=

# Optional webhook/runtime tuning (defaults in code if omitted)
MONO_REFUND_ENABLED=0
MONO_WEBHOOK_CLAIM_TTL_MS=
MONO_WEBHOOK_MODE=

PAYMENTS_ENABLED=

# --- Shipping (Nova Poshta)
# Toggles (optional; defaults are handled in code)
SHOP_SHIPPING_ENABLED=0
SHOP_SHIPPING_NP_ENABLED=0

# Retention (optional; days, used for cleanup/retention policies)
SHOP_SHIPPING_RETENTION_DAYS=

# Required when shipping is enabled (SHOP_SHIPPING_ENABLED=1 and SHOP_SHIPPING_NP_ENABLED=1).
# If shipping is enabled without required NP config, app throws NovaPoshtaConfigError at runtime.
# Optional if code has a default; set explicitly in production for clarity
Comment on lines +74 to +82
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Shipping toggle examples/comments conflict with runtime parsing.

This file suggests enabling shipping with =1, but frontend/lib/env/nova-poshta.ts checks for literal 'true'. Using 1 will keep features disabled unexpectedly.

💡 Suggested patch
-# Toggles (optional; defaults are handled in code)
-SHOP_SHIPPING_ENABLED=0
-SHOP_SHIPPING_NP_ENABLED=0
+# Toggles (optional; runtime checks expect "true"/"false")
+SHOP_SHIPPING_ENABLED=false
+SHOP_SHIPPING_NP_ENABLED=false

-# Required when shipping is enabled (SHOP_SHIPPING_ENABLED=1 and SHOP_SHIPPING_NP_ENABLED=1).
+# Required when shipping is enabled (SHOP_SHIPPING_ENABLED=true and SHOP_SHIPPING_NP_ENABLED=true).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/.env.example` around lines 74 - 82, The .env.example suggests
enabling shipping with SHOP_SHIPPING_ENABLED=1/SHOP_SHIPPING_NP_ENABLED=1 but
frontend/lib/env/nova-poshta.ts checks for the literal 'true', causing the
toggle to stay disabled; fix by making the toggle parsing robust and/or the
example consistent: update frontend/lib/env/nova-poshta.ts to parse
SHOP_SHIPPING_ENABLED and SHOP_SHIPPING_NP_ENABLED with a small helper (e.g.,
isTruthyEnv) that treats '1', 'true', 'yes' (case-insensitive) as true and falsy
values as false, and also update the .env.example comment to show the accepted
true form(s) (or change it to use 'true' explicitly) so both the code and
documentation match.

NP_API_BASE=
NP_API_KEY=
NP_SENDER_WAREHOUSE_REF=
NP_SENDER_CITY_REF=
NP_SENDER_CONTACT_REF=
NP_SENDER_NAME=
NP_SENDER_PHONE=
NP_SENDER_REF=

# Optional tuning (override only if needed; otherwise code defaults apply)
NP_MAX_RETRIES=
NP_RETRY_DELAY_MS=
NP_TIMEOUT_MS=

# --- Admin / Internal ops
ENABLE_ADMIN_API=
INTERNAL_JANITOR_MIN_INTERVAL_SECONDS=
INTERNAL_JANITOR_SECRET=
JANITOR_URL=

# Optional internal/admin runtime secrets & tuning (used by internal endpoints/jobs)
INTERNAL_SECRET=
JANITOR_TIMEOUT_MS=

# Optional instance IDs for webhook multi-instance diagnostics/claiming
STRIPE_WEBHOOK_INSTANCE_ID=
WEBHOOK_INSTANCE_ID=

# --- Quiz
QUIZ_ENCRYPTION_KEY=

# --- Web3Forms (feedback form)
NEXT_PUBLIC_WEB3FORMS_KEY=

GITHUB_SPONSORS_TOKEN=
NEXT_PUBLIC_WEB3FORMS_KEY=

# --- Telegram
TELEGRAM_BOT_TOKEN=
Expand All @@ -75,6 +124,13 @@ EMAIL_FROM=
GMAIL_APP_PASSWORD=
GMAIL_USER=

# --- Shop / Internal
# Optional public/base URL used by shop services/links
SHOP_BASE_URL=

# Required for signed shop status tokens (if status endpoint/token flow is enabled)
SHOP_STATUS_TOKEN_SECRET=

# --- Security
CSRF_SECRET=

Expand Down Expand Up @@ -107,6 +163,4 @@ TRUST_FORWARDED_HEADERS=0
# emergency switch
RATE_LIMIT_DISABLED=0

GROQ_API_KEY=

NEXT_PUBLIC_WEB3FORMS_KEY=
GROQ_API_KEY=
4 changes: 0 additions & 4 deletions frontend/actions/notifications.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
'use server';

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

import { db } from '@/db';
import { notifications } from '@/db/schema/notifications';
Expand Down Expand Up @@ -39,7 +38,6 @@ export async function markAsRead(notificationId: string) {
)
);

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

revalidatePath('/', 'layout');
return { success: true };
} catch (error) {
console.error('Failed to mark all notifications as read:', error);
Expand Down Expand Up @@ -86,7 +83,6 @@ export async function createNotification(data: {
})
.returning();

revalidatePath('/', 'layout');
return result;
} catch (error) {
console.error('Failed to create notification:', error);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import { useId, useState, useTransition } from 'react';
type Props = {
orderId: string;
disabled: boolean;
csrfToken: string;
};

export function RefundButton({ orderId, disabled }: Props) {
export function RefundButton({ orderId, disabled, csrfToken }: Props) {
const router = useRouter();
const t = useTranslations('shop.admin.refund');
const [isPending, startTransition] = useTransition();
Expand All @@ -24,7 +25,10 @@ export function RefundButton({ orderId, disabled }: Props) {
res = await fetch(`/api/shop/admin/orders/${orderId}/refund`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken,
},
});
} catch (err) {
const msg =
Expand Down
137 changes: 137 additions & 0 deletions frontend/app/[locale]/admin/shop/orders/[id]/ShippingActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
'use client';

import { useRouter } from 'next/navigation';
import { useId, useState, useTransition } from 'react';

type ActionName = 'retry_label_creation' | 'mark_shipped' | 'mark_delivered';

type Props = {
orderId: string;
csrfToken: string;
shippingStatus: string | null;
shipmentStatus: string | null;
};

function actionEnabled(args: {
action: ActionName;
shippingStatus: string | null;
shipmentStatus: string | null;
}): boolean {
if (args.action === 'retry_label_creation') {
return (
args.shipmentStatus === 'failed' || args.shipmentStatus === 'needs_attention'
);
}
if (args.action === 'mark_shipped') {
return args.shippingStatus === 'label_created';
}
return args.shippingStatus === 'shipped';
}

export function ShippingActions({
orderId,
csrfToken,
shippingStatus,
shipmentStatus,
}: Props) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const errorId = useId();

async function runAction(action: ActionName) {
setError(null);

let res: Response;
try {
res = await fetch(`/api/shop/admin/orders/${orderId}/shipping`, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken,
},
body: JSON.stringify({ action }),
});
} catch (err) {
const msg =
err instanceof Error && err.message ? err.message : 'NETWORK_ERROR';
setError(msg);
return;
}

let json: any = null;
try {
json = await res.json();
} catch {
// ignore
}

if (!res.ok) {
setError(json?.code ?? json?.message ?? `HTTP_${res.status}`);
return;
}

startTransition(() => {
router.refresh();
});
}

const retryEnabled = actionEnabled({
action: 'retry_label_creation',
shippingStatus,
shipmentStatus,
});
const shippedEnabled = actionEnabled({
action: 'mark_shipped',
shippingStatus,
shipmentStatus,
});
const deliveredEnabled = actionEnabled({
action: 'mark_delivered',
shippingStatus,
shipmentStatus,
});

return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => runAction('retry_label_creation')}
disabled={isPending || !retryEnabled}
aria-busy={isPending}
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"
>
Retry label creation
</button>

<button
type="button"
onClick={() => runAction('mark_shipped')}
disabled={isPending || !shippedEnabled}
aria-busy={isPending}
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"
>
Mark shipped
</button>

<button
type="button"
onClick={() => runAction('mark_delivered')}
disabled={isPending || !deliveredEnabled}
aria-busy={isPending}
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"
>
Mark delivered
</button>
</div>

{error ? (
<p id={errorId} role="alert" className="text-destructive text-xs">
{error}
</p>
) : null}
</div>
);
}
Loading