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
6 changes: 6 additions & 0 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
reviews:
auto_review:
enabled: true
base_branches:
- develop
3 changes: 1 addition & 2 deletions .github/workflows/shop-janitor-restock-stale.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Shop janitor - restock stale orders

on:
schedule:
- cron: "*/5 * * * *" # every 5 minutes (UTC)
- cron: "*/5 * * * *"
workflow_dispatch: {}

concurrency:
Expand Down Expand Up @@ -30,7 +30,6 @@ jobs:
with:
node-version: "20"

# Step-level guard: тут secrets дозволені
- name: Guard config (skip if secrets missing)
id: guard
run: |
Expand Down
14 changes: 9 additions & 5 deletions frontend/app/[locale]/shop/admin/orders/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ export const metadata: Metadata = {
description: 'View and manage orders in the DevLovers shop catalog.',
};


export const dynamic = 'force-dynamic';

const PAGE_SIZE = 25;
Expand Down Expand Up @@ -61,7 +60,6 @@ export default async function AdminOrdersPage({
const page = parsePage(sp.page);
const offset = (page - 1) * PAGE_SIZE;

// overfetch for hasNext without COUNT
const { items: all } = await getAdminOrdersPage({
limit: PAGE_SIZE + 1,
offset,
Expand Down Expand Up @@ -147,12 +145,16 @@ export default async function AdminOrdersPage({

<dl className="mt-3 grid grid-cols-2 gap-x-3 gap-y-2 text-xs">
<div>
<dt className="text-muted-foreground">{t('table.items')}</dt>
<dt className="text-muted-foreground">
{t('table.items')}
</dt>
<dd className="text-foreground">{vm.itemCount}</dd>
</div>

<div className="min-w-0">
<dt className="text-muted-foreground">{t('table.provider')}</dt>
<dt className="text-muted-foreground">
{t('table.provider')}
</dt>
<dd
className="truncate text-foreground"
title={vm.paymentProvider}
Expand All @@ -162,7 +164,9 @@ export default async function AdminOrdersPage({
</div>

<div className="col-span-2">
<dt className="text-muted-foreground">{t('table.orderId')}</dt>
<dt className="text-muted-foreground">
{t('table.orderId')}
</dt>
<dd
className="break-all font-mono text-[11px] text-muted-foreground"
title={vm.id}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,6 @@ export function ProductForm({
Partial<Record<CurrencyCode, string>>
>({});

// Hydrate state from initialValues once per product in EDIT mode.
// In edit: slug must come from DB and stay stable (no title->slug regeneration).
useEffect(() => {
if (mode !== 'edit') {
hydratedKeyRef.current = null;
Expand All @@ -221,8 +219,6 @@ export function ProductForm({

if (hydratedKeyRef.current === key) return;

// Reset transient UI state when switching between products in EDIT mode.
// Do NOT do this in submit: it breaks retries (e.g., clears selected image).
setError(null);
setSlugError(null);
setImageError(null);
Expand Down Expand Up @@ -251,8 +247,7 @@ export function ProductForm({
}, [mode, initialValues, productId]);

const slugValue = useMemo(() => {
if (mode === 'edit') return slug; // slug в edit має бути стабільним (з БД)
// In create mode, always derive from current title to avoid stale slug on fast submit.
if (mode === 'edit') return slug;
return localSlugify(title);
}, [mode, slug, title]);

Expand Down Expand Up @@ -967,8 +962,8 @@ export function ProductForm({
? 'Creating...'
: 'Updating...'
: mode === 'create'
? 'Create product'
: 'Save changes'}
? 'Create product'
: 'Save changes'}
</button>
</form>
</section>
Expand Down
1 change: 0 additions & 1 deletion frontend/app/[locale]/shop/cart/CartPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,6 @@ export default function CartPage() {
{t('checkout.message')}
</p>

{/* Fallback CTA if navigation fails after order was created */}
{createdOrderId && !checkoutError ? (
<div className="flex justify-center">
<Link
Expand Down
102 changes: 73 additions & 29 deletions frontend/app/[locale]/shop/checkout/payment/StripePaymentClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ import {
useElements,
useStripe,
} from '@stripe/react-stripe-js';
import { loadStripe, type StripeElementsOptions, type Stripe } from '@stripe/stripe-js';
import {
loadStripe,
type StripeElementsOptions,
type Stripe,
} from '@stripe/stripe-js';

import {
currencyValues,
Expand Down Expand Up @@ -57,11 +61,6 @@ function toCurrencyCode(
: resolveCurrencyFromLocale(locale);
}

/**
* IMPORTANT:
* - In-app navigation uses next-intl routing -> DO NOT prefix locale manually.
* - Stripe return_url is an external redirect -> MUST include locale exactly once.
*/
const IN_APP_SHOP_BASE = '/shop';

function normalizeLocale(raw: string): string {
Expand All @@ -73,13 +72,21 @@ function buildInAppPath(path: string): string {
return `${IN_APP_SHOP_BASE}${p}`;
}

function buildStripeReturnUrl(params: { locale: string; inAppPath: string }): string {
function buildStripeReturnUrl(params: {
locale: string;
inAppPath: string;
}): string {
const loc = normalizeLocale(params.locale);
const p = params.inAppPath.startsWith('/') ? params.inAppPath : `/${params.inAppPath}`;
const p = params.inAppPath.startsWith('/')
? params.inAppPath
: `/${params.inAppPath}`;
return new URL(`/${loc}${p}`, window.location.origin).toString();
}

function nextRouteForPaymentResult(params: { orderId: string; status?: string | null }) {
function nextRouteForPaymentResult(params: {
orderId: string;
status?: string | null;
}) {
const { orderId, status } = params;
const id = encodeURIComponent(orderId);

Expand All @@ -88,7 +95,11 @@ function nextRouteForPaymentResult(params: { orderId: string; status?: string |

if (!status) return success;

if (status === 'succeeded' || status === 'processing' || status === 'requires_capture') {
if (
status === 'succeeded' ||
status === 'processing' ||
status === 'requires_capture'
) {
return success;
}

Expand All @@ -99,7 +110,6 @@ function nextRouteForPaymentResult(params: { orderId: string; status?: string |
return success;
}

/** Unified CTA (hero) */
const SHOP_HERO_CTA = cn(
SHOP_CTA_BASE,
SHOP_CTA_INTERACTIVE,
Expand All @@ -110,7 +120,6 @@ const SHOP_HERO_CTA = cn(
'shadow-[var(--shop-hero-btn-shadow)] hover:shadow-[var(--shop-hero-btn-shadow-hover)]'
);

/** Unified outline */
const SHOP_OUTLINE = cn(
SHOP_OUTLINE_BTN_BASE,
SHOP_OUTLINE_BTN_INTERACTIVE,
Expand All @@ -122,19 +131,22 @@ const SHOP_OUTLINE = cn(
function HeroCtaInner({ children }: { children: React.ReactNode }) {
return (
<>
{/* base gradient */}
<span
className="absolute inset-0"
style={shopCtaGradient('--shop-hero-btn-bg', '--shop-hero-btn-bg-hover')}
style={shopCtaGradient(
'--shop-hero-btn-bg',
'--shop-hero-btn-bg-hover'
)}
aria-hidden="true"
/>
{/* hover wave overlay */}
<span
className={SHOP_CTA_WAVE}
style={shopCtaGradient('--shop-hero-btn-bg-hover', '--shop-hero-btn-bg')}
style={shopCtaGradient(
'--shop-hero-btn-bg-hover',
'--shop-hero-btn-bg'
)}
aria-hidden="true"
/>
{/* glass inset */}
<span className={SHOP_CTA_INSET} aria-hidden="true" />

<span className="relative z-10">{children}</span>
Expand All @@ -155,7 +167,9 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
setErrorMessage(null);

if (!stripe || !elements) {
setErrorMessage('Payment is not ready yet. Please try again in a moment.');
setErrorMessage(
'Payment is not ready yet. Please try again in a moment.'
);
return;
}

Expand Down Expand Up @@ -190,14 +204,20 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
} catch (error) {
logError('stripe_payment_confirm_failed', error, { orderId });
setErrorMessage('We couldn’t confirm your payment. Please try again.');
router.push(buildInAppPath(`/checkout/error?orderId=${encodeURIComponent(orderId)}`));
router.push(
buildInAppPath(`/checkout/error?orderId=${encodeURIComponent(orderId)}`)
);
} finally {
setSubmitting(false);
}
}

return (
<form onSubmit={handleSubmit} className="space-y-4" aria-label="Stripe payment form">
<form
onSubmit={handleSubmit}
className="space-y-4"
aria-label="Stripe payment form"
>
<PaymentElement />

<button
Expand All @@ -206,7 +226,9 @@ function StripePaymentForm({ orderId, locale }: PaymentFormProps) {
className={SHOP_HERO_CTA}
aria-disabled={!stripe || submitting}
>
<HeroCtaInner>{submitting ? 'Processing...' : 'Submit payment'}</HeroCtaInner>
<HeroCtaInner>
{submitting ? 'Processing...' : 'Submit payment'}
</HeroCtaInner>
</button>

{errorMessage ? (
Expand All @@ -227,7 +249,10 @@ export default function StripePaymentClient({
currency,
locale,
}: StripePaymentClientProps) {
const uiCurrency = useMemo(() => toCurrencyCode(currency, locale), [currency, locale]);
const uiCurrency = useMemo(
() => toCurrencyCode(currency, locale),
[currency, locale]
);

const stripePromise = useMemo(() => {
if (!paymentsEnabled || !publishableKey) return null;
Expand All @@ -244,18 +269,29 @@ export default function StripePaymentClient({

if (!paymentsEnabled) {
return (
<section className="space-y-3 text-sm text-muted-foreground" aria-label="Payments disabled">
<section
className="space-y-3 text-sm text-muted-foreground"
aria-label="Payments disabled"
>
<p>Payments are disabled in this environment.</p>

<nav className="flex flex-col gap-3 sm:flex-row" aria-label="Next steps">
<nav
className="flex flex-col gap-3 sm:flex-row"
aria-label="Next steps"
>
<Link
href={buildInAppPath(`/checkout/success?orderId=${encodeURIComponent(orderId)}`)}
href={buildInAppPath(
`/checkout/success?orderId=${encodeURIComponent(orderId)}`
)}
className={cn(SHOP_HERO_CTA, 'w-full sm:w-auto')}
>
<HeroCtaInner>Continue</HeroCtaInner>
</Link>

<Link href={buildInAppPath('/cart')} className={cn(SHOP_OUTLINE, 'w-full sm:w-auto')}>
<Link
href={buildInAppPath('/cart')}
className={cn(SHOP_OUTLINE, 'w-full sm:w-auto')}
>
Back to cart
</Link>
</nav>
Expand All @@ -265,10 +301,16 @@ export default function StripePaymentClient({

if (!clientSecret || !clientSecret.trim()) {
return (
<section className="space-y-3 text-sm text-muted-foreground" aria-label="Payment initialization failed">
<section
className="space-y-3 text-sm text-muted-foreground"
aria-label="Payment initialization failed"
>
<p>Payment cannot be initialized. Please try again later.</p>

<Link href={buildInAppPath('/cart')} className={cn(SHOP_OUTLINE, 'w-full sm:w-auto')}>
<Link
href={buildInAppPath('/cart')}
className={cn(SHOP_OUTLINE, 'w-full sm:w-auto')}
>
Return to cart
</Link>
</section>
Expand All @@ -295,7 +337,9 @@ export default function StripePaymentClient({
</span>
</div>

<p className="text-xs uppercase tracking-wide text-muted-foreground">{uiCurrency}</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground">
{uiCurrency}
</p>
</div>

<StripePaymentForm orderId={orderId} locale={locale} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,6 @@ function HeroCtaLink({
}) {
return (
<Link href={href} className={cn(SHOP_HERO_CTA_SM, className)}>
{/* base gradient */}
<span
className="absolute inset-0"
style={shopCtaGradient(
Expand All @@ -115,7 +114,6 @@ function HeroCtaLink({
)}
aria-hidden="true"
/>
{/* glass inset */}
<span className={SHOP_CTA_INSET} aria-hidden="true" />

<span className="relative z-10">{children}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,5 @@ export default function OrderStatusAutoRefresh({
return () => window.clearInterval(id);
}, [paymentStatus, router, maxMs, intervalMs]);

// Non-visual utility component (keeps page data fresh while payment settles).
return <span className="sr-only" aria-live="polite" />;
}
Loading