Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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,85 @@
'use client';

import { CreditCard, Loader2 } from 'lucide-react';
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 formAction = async (_prevState: ActionState): Promise<ActionState> => {
try {
const { alreadyClaimed, url } = await createCardBonusSetupSession();
Comment thread
gianpaj marked this conversation as resolved.
Outdated

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
Loading