Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
3 changes: 0 additions & 3 deletions .biomeignore

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
'use client';

import { CreditCard, Loader2 } from 'lucide-react';
import { useParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useActionState } from 'react';
Comment thread
gianpaj marked this conversation as resolved.

import { createCardBonusSetupSession } from '@/app/[lang]/actions/stripe';
import { Button } from '@/components/ui/button';

interface ActionState {
alreadyClaimed: boolean;
error: string | null;
}

const initialState: ActionState = {
alreadyClaimed: false,
error: null,
};

export function CardBonusCta() {
const t = useTranslations('credits.cardBonus');
const { lang } = useParams();

const formAction = async (_prevState: ActionState): Promise<ActionState> => {
try {
const { alreadyClaimed, url } = await createCardBonusSetupSession({
lang,
});

if (alreadyClaimed) {
return { alreadyClaimed: true, error: null };
}

if (url) {
window.location.assign(url);
return { alreadyClaimed: false, error: null };
}

throw new Error('No setup session URL received');
} catch (error) {
console.error('Error creating card bonus setup session:', error);
return {
alreadyClaimed: false,
error: t('error'),
};
}
};

const [state, formActionDispatch, pending] = useActionState(
formAction,
initialState,
);

if (state.alreadyClaimed) {
return null;
}

return (
<div className="flex flex-col justify-between gap-4 rounded-lg border border-primary/30 bg-primary/5 p-4 sm:flex-row sm:items-center">
<div className="flex items-center gap-3">
<CreditCard className="size-5 shrink-0 text-primary" />
<div>
<h4 className="font-semibold text-sm">{t('title')}</h4>
<p className="text-muted-foreground text-sm">{t('description')}</p>
{state.error ? (
<p className="mt-1 text-red-600 text-xs">{state.error}</p>
) : null}
</div>
</div>
<form action={formActionDispatch}>
<Button
className="hit-area-6 w-full sm:w-auto"
disabled={pending}
type="submit"
>
{pending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('processing')}
</>
) : (
t('cta')
)}
</Button>
</form>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,14 @@ export async function CreditHistory({
</time>
</TableCell>
<TableCell>{transaction.description}</TableCell>
<TableCell className="capitalize">{transaction.type}</TableCell>
<TableCell className="capitalize">
{t(`historyTypes.${transaction.type}`)}
</TableCell>
<TableCell
className={`text-right ${
['purchase', 'freemium', 'topup'].includes(transaction.type)
['purchase', 'freemium', 'topup', 'card_bonus'].includes(
transaction.type,
)
? 'text-green-600'
: 'text-red-600'
}`}
Expand Down
10 changes: 9 additions & 1 deletion apps/web/app/[lang]/(dashboard)/dashboard/credits/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ import {
isStripeCouponUsable,
refreshCustomerSubscriptionData,
} from '@/lib/stripe/stripe-admin';
import { getUserByIdWithError } from '@/lib/supabase/queries';
import {
getUserByIdWithError,
isEligibleForCardBonus,
} from '@/lib/supabase/queries';
import { createClient } from '@/lib/supabase/server';
import { CardBonusCta } from './card-bonus-cta';
import { CreditHistory } from './credit-history';
import { PaymentStatus } from './payment-status';

Expand Down Expand Up @@ -113,6 +117,7 @@ export default async function CreditsPage(props: {
let shouldShowSubscriptionPlans = false;
let isEligibleForSubscriptionDiscount = false;
let scheduledSubscriptionEndDate: string | null = null;
let showCardBonusCta = false;
let existingTransactions:
| Pick<
Tables<'credit_transactions'>,
Expand Down Expand Up @@ -170,6 +175,8 @@ export default async function CreditsPage(props: {
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(100));

showCardBonusCta = await isEligibleForCardBonus(user.id);
}

const subscriptionDiscountPercent =
Expand All @@ -185,6 +192,7 @@ export default async function CreditsPage(props: {
<Suspense>
<PaymentStatus />
</Suspense>
{showCardBonusCta ? <CardBonusCta /> : null}
<div className="flex flex-col justify-between gap-4 lg:flex-row">
<div className="w-full lg:w-3/4">
<h3 className="mb-4 font-semibold text-lg">{t('topup.title')}</h3>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,33 @@ import { useEffect, useState } from 'react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';

type PaymentStatusKind =
| 'canceled'
| 'cardBonusCanceled'
| 'cardBonusSuccess'
| 'error'
| 'success'
| null;

function resolveStatus(
searchParams: Pick<URLSearchParams, 'get'>,
): PaymentStatusKind {
if (searchParams.get('success') === 'true') return 'success';
if (searchParams.get('canceled') === 'true') return 'canceled';
if (searchParams.get('error')) return 'error';

const cardBonus = searchParams.get('card_bonus');
if (cardBonus === 'success') return 'cardBonusSuccess';
if (cardBonus === 'canceled') return 'cardBonusCanceled';

return null;
}

export function PaymentStatus() {
const t = useTranslations('credits.status');
const searchParams = useSearchParams();
const creditsAmount = searchParams.get('creditsAmount');

const success = searchParams.get('success');
const canceled = searchParams.get('canceled');
const error = searchParams.get('error');

const status: 'success' | 'canceled' | 'error' | null =
success === 'true'
? 'success'
: canceled === 'true'
? 'canceled'
: error
? 'error'
: null;
const status = resolveStatus(searchParams);

const [isVisible, setIsVisible] = useState(() => status !== null);

Expand All @@ -42,6 +52,7 @@ export function PaymentStatus() {
url.searchParams.delete('canceled');
url.searchParams.delete('error');
url.searchParams.delete('amount');
url.searchParams.delete('card_bonus');
window.history.replaceState({}, '', url.toString());
};

Expand Down Expand Up @@ -112,6 +123,49 @@ export function PaymentStatus() {
</AlertDescription>
</Alert>
)}

{status === 'cardBonusSuccess' && (
<Alert className="">
<CircleCheckIcon
aria-hidden="true"
className="me-3 -mt-0.5 h-4 w-4 text-emerald-500!"
/>
Comment thread
gianpaj marked this conversation as resolved.
<AlertTitle>{t('cardBonusSuccess.title')}</AlertTitle>
<AlertDescription className="text-muted-foreground">
{t('cardBonusSuccess.description')}
<div className="mt-2">
<Button
className="border-green-300 hover:bg-green-900"
onClick={handleDismiss}
size="sm"
variant="outline"
>
{t('cardBonusSuccess.dismiss')}
</Button>
</div>
</AlertDescription>
</Alert>
)}

{status === 'cardBonusCanceled' && (
<Alert className="border-amber-900 bg-amber-950 text-amber-50">
<XCircle className="h-4 w-4" />
<AlertTitle>{t('cardBonusCanceled.title')}</AlertTitle>
<AlertDescription>
{t('cardBonusCanceled.description')}
<div className="mt-2">
<Button
className="hover:bg-secondary/50"
onClick={handleDismiss}
size="sm"
variant="secondary"
>
{t('cardBonusCanceled.dismiss')}
</Button>
</div>
</AlertDescription>
</Alert>
)}
</div>
);
}
26 changes: 17 additions & 9 deletions apps/web/app/[lang]/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { ReactQueryClientProvider } from '@/components/ReactQueryClientProvider'
import { resolveActiveBanner } from '@/lib/banners/resolve-banner';
import { E2E_CREDIT_TRANSACTIONS, isE2E } from '@/lib/e2e-mocks';
import type { Locale } from '@/lib/i18n/i18n-config';
import { hasUserPaid } from '@/lib/supabase/queries';
import {
computeCardBonusEligibility,
hasUserPaid,
} from '@/lib/supabase/queries';
import {
getCreditsQuery,
getCreditTransactions,
Expand Down Expand Up @@ -40,14 +43,6 @@ export default async function DashboardLayout(props: {
.filter((cookie) => cookie.value)
.map((cookie) => cookie.name);

const activeBanner = resolveActiveBanner({
audience: 'loggedIn',
dismissedCookieKeys,
lang,
messages,
placement: 'dashboard',
});

const [{ data: creditTransactions }, isPaidUser] = isE2E()
? [{ data: E2E_CREDIT_TRANSACTIONS }, false]
: await Promise.all([
Expand All @@ -58,6 +53,19 @@ export default async function DashboardLayout(props: {
await prefetchQuery(queryClient, getCreditsQuery(supabase, user.id));
}

const cardBonusEligible = isE2E()
? false
: computeCardBonusEligibility(creditTransactions ?? []);

const activeBanner = resolveActiveBanner({
audience: 'loggedIn',
cardBonusEligible,
dismissedCookieKeys,
lang,
messages,
placement: 'dashboard',
});

return (
<ReactQueryClientProvider>
<HydrationBoundary state={dehydrate(queryClient)}>
Expand Down
Loading