feat: stripe changes

This commit is contained in:
Nevo David 2026-01-17 11:42:12 +07:00
parent 07b0c2e85d
commit ba4ad5deb2
4 changed files with 395 additions and 22 deletions

View File

@ -20,7 +20,9 @@ import { useT } from '@gitroom/react/translation/get.transation.service.client';
export const EmbeddedBilling: FC<{
stripe: Promise<Stripe>;
secret: string;
}> = ({ stripe, secret }) => {
showCoupon?: boolean;
autoApplyCoupon?: string;
}> = ({ stripe, secret, showCoupon = false, autoApplyCoupon }) => {
const [saveSecret, setSaveSecret] = useState(secret);
const [loading, setLoading] = useState(false);
const [mode, setMode] = useCookie('mode', 'dark');
@ -80,18 +82,24 @@ export const EmbeddedBilling: FC<{
},
}}
>
<FormWrapper />
<FormWrapper
showCoupon={showCoupon}
autoApplyCoupon={autoApplyCoupon}
/>
</CheckoutProvider>
</div>
);
};
const FormWrapper = () => {
const FormWrapper: FC<{ showCoupon?: boolean; autoApplyCoupon?: string }> = ({
showCoupon = false,
autoApplyCoupon,
}) => {
const checkoutState = useCheckout();
const toaster = useToaster();
const [loading, setLoading] = useState(false);
if (checkoutState.type === 'loading' || checkoutState.type === 'error') {
if (checkoutState.type !== 'success') {
return null;
}
@ -112,15 +120,19 @@ const FormWrapper = () => {
return (
<form onSubmit={handleSubmit} className="flex flex-col flex-1">
<StripeInputs />
<StripeInputs showCoupon={showCoupon} autoApplyCoupon={autoApplyCoupon} />
<SubmitBar loading={loading} />
</form>
);
};
const StripeInputs = () => {
const StripeInputs: FC<{ showCoupon: boolean; autoApplyCoupon?: string }> = ({
showCoupon,
autoApplyCoupon,
}) => {
const checkout = useCheckout();
const t = useT();
const [ready, setReady] = useState(false);
return (
<>
<div>
@ -135,14 +147,12 @@ const StripeInputs = () => {
<h4 className="mt-[40px] mb-[32px] text-[24px] font-[700]">
{checkout.type === 'loading' ? '' : t('billing_payment', 'Payment')}
</h4>
<PaymentElement id="payment-element" options={{ layout: 'tabs' }} />
<PaymentElement id="payment-element" options={{ layout: 'tabs' }} onReady={() => setReady(true)} />
{showCoupon && ready && <CouponInput autoApplyCoupon={autoApplyCoupon} />}
{checkout.type === 'loading' ? null : (
<div className="mt-[24px] text-[16px] font-[600] flex gap-[4px] items-center">
<div>
{t(
'billing_powered_by_stripe',
'Secure payments processed by'
)}
{t('billing_powered_by_stripe', 'Secure payments processed by')}
</div>
<svg
className="mt-[4px]"
@ -166,6 +176,302 @@ const StripeInputs = () => {
);
};
const AppliedCouponDisplay: FC<{
appliedCode: string;
checkout: any;
isApplying: boolean;
onRemove: () => void;
}> = ({ appliedCode, checkout, isApplying, onRemove }) => {
const t = useT();
// Get discount display from checkout state
const getDiscountDisplay = (): string | null => {
// Try to get percentage from discountAmounts
const percentOff = checkout?.discountAmounts?.[0]?.percentOff;
if (percentOff && typeof percentOff === 'number' && percentOff > 0) {
return `-${percentOff}%`;
}
// Try to get actual discount amount from recurring.dueNext.discount
const recurringDiscount =
checkout?.recurring?.dueNext?.discount?.minorUnitsAmount;
if (
recurringDiscount &&
typeof recurringDiscount === 'number' &&
recurringDiscount > 0
) {
return `-$${(recurringDiscount / 100).toFixed(2)}`;
}
// Try lineItems discount
const lineItemDiscount =
checkout?.lineItems?.[0]?.discountAmounts?.[0]?.percentOff;
if (
lineItemDiscount &&
typeof lineItemDiscount === 'number' &&
lineItemDiscount > 0
) {
return `-${lineItemDiscount}%`;
}
return null;
};
// Get expiration date from checkout state (if available)
const getExpirationDate = (): string | null => {
const discount = checkout?.discountAmounts?.[0];
const lineItemDiscount = checkout?.lineItems?.[0]?.discountAmounts?.[0];
// Check for expiresAt in various locations (Unix timestamp)
const expiresAt =
discount?.expiresAt ||
discount?.expires_at ||
lineItemDiscount?.expiresAt ||
lineItemDiscount?.expires_at ||
checkout?.promotionCode?.expiresAt ||
checkout?.promotionCode?.expires_at;
if (expiresAt && typeof expiresAt === 'number') {
const date = new Date(expiresAt * 1000);
return dayjs(date).format('MMMM D, YYYY');
}
if (expiresAt && typeof expiresAt === 'string') {
return dayjs(expiresAt).format('MMMM D, YYYY');
}
return null;
};
const discountDisplay = getDiscountDisplay();
const expirationDate = getExpirationDate();
return (
<div className="flex flex-col gap-[8px]">
<div className="flex items-center gap-[12px] p-[16px] rounded-[12px] border border-[#AA0FA4]/30 bg-[#AA0FA4]/10">
<div className="flex-1">
<div className="flex items-center gap-[8px] flex-wrap">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="#FC69FF"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<span className="font-[600] text-[#FC69FF]">{appliedCode}</span>
<span className="text-[14px] text-textColor/70">
{t('billing_discount_applied', 'applied')}
{discountDisplay && ` (${discountDisplay})`}
</span>
</div>
</div>
<button
type="button"
onClick={onRemove}
disabled={isApplying}
className="text-[14px] text-textColor/50 hover:text-textColor font-[500] disabled:opacity-50"
>
{t('billing_remove', 'Remove')}
</button>
</div>
{expirationDate && (
<p className="text-[13px] text-textColor/50 flex items-center gap-[6px]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
{t('billing_coupon_expires', 'Coupon expires on')} {expirationDate}
</p>
)}
</div>
);
};
export const CouponInput: FC<{ autoApplyCoupon?: string }> = ({
autoApplyCoupon,
}) => {
const checkoutState = useCheckout();
const t = useT();
const toaster = useToaster();
const [couponCode, setCouponCode] = useState('');
const [isApplying, setIsApplying] = useState(false);
const [appliedCode, setAppliedCode] = useState<string | null>(null);
const [showInput, setShowInput] = useState(false);
const { checkout } =
checkoutState.type === 'success' ? checkoutState : { checkout: null };
// Auto-apply coupon from backend when checkout is ready
useEffect(() => {
if (autoApplyCoupon) {
handleApplyCoupon(undefined, autoApplyCoupon);
}
}, []);
// Check if a coupon is already pre-applied (e.g., auto-apply coupon from backend)
const preAppliedCode = checkout?.discountAmounts?.[0]?.promotionCode;
const effectiveAppliedCode = appliedCode || preAppliedCode || null;
const handleApplyCoupon = async (e?: any, coupon?: string) => {
if (!coupon && !couponCode.trim()) return;
setIsApplying(true);
try {
const result = await checkout.applyPromotionCode(
coupon || couponCode.trim()
);
if (result.type === 'error') {
toaster.show(
result.error.message ||
t('billing_invalid_coupon', 'Invalid coupon code'),
'warning'
);
} else {
setAppliedCode(coupon || couponCode.trim());
setCouponCode('');
setShowInput(false);
toaster.show(
t('billing_coupon_applied', 'Coupon applied successfully!'),
'success'
);
}
} catch (err: any) {
toaster.show(
err.message || t('billing_invalid_coupon', 'Invalid coupon code'),
'warning'
);
}
setIsApplying(false);
};
const handleRemoveCoupon = async () => {
setIsApplying(true);
try {
await checkout.removePromotionCode();
setAppliedCode(null);
toaster.show(t('billing_coupon_removed', 'Coupon removed'), 'success');
} catch (err: any) {
toaster.show(
err.message ||
t('billing_error_removing_coupon', 'Error removing coupon'),
'warning'
);
}
setIsApplying(false);
};
// Show applied coupon (either manually applied or pre-applied from backend)
if (effectiveAppliedCode) {
return (
<div className="mt-[40px]">
<AppliedCouponDisplay
appliedCode={effectiveAppliedCode}
checkout={checkout}
isApplying={isApplying}
onRemove={handleRemoveCoupon}
/>
</div>
);
}
// Show "Have a promo code?" link
if (!showInput) {
return (
<div className="mt-[40px]">
<button
type="button"
onClick={() => setShowInput(true)}
className="text-[16px] text-textColor/60 hover:text-textColor font-[500] flex items-center gap-[8px] transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
{t('billing_have_discount_coupon', 'Have a discount coupon?')}
</button>
</div>
);
}
// Show input field
return (
<div className="mt-[40px]">
<div className="flex items-center gap-[12px] mb-[12px]">
<h4 className="text-[18px] font-[600] text-textColor">
{t('billing_discount_coupon', 'Discount Coupon')}
</h4>
<button
type="button"
onClick={() => {
setShowInput(false);
setCouponCode('');
}}
className="text-[14px] text-textColor/50 hover:text-textColor transition-colors"
>
{t('billing_cancel', 'Cancel')}
</button>
</div>
<div className="flex gap-[12px]">
<input
type="text"
value={couponCode}
onChange={(e) => setCouponCode(e.target.value)}
placeholder={t('billing_enter_coupon_code', 'Enter coupon code')}
disabled={isApplying}
autoFocus
className="flex-1 h-[44px] px-[16px] rounded-[8px] border border-newColColor bg-newBgColor text-textColor placeholder:text-textColor/50 focus:outline-none focus:border-boxFocused disabled:opacity-50"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleApplyCoupon();
}
if (e.key === 'Escape') {
setShowInput(false);
setCouponCode('');
}
}}
/>
<button
type="button"
onClick={() => handleApplyCoupon()}
disabled={isApplying || !couponCode.trim()}
className="h-[44px] px-[24px] rounded-[8px] bg-boxFocused text-textItemFocused font-[600] hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
{isApplying
? t('billing_applying', 'Applying...')
: t('billing_apply', 'Apply')}
</button>
</div>
</div>
);
};
const SubmitBar: FC<{ loading: boolean }> = ({ loading }) => {
const checkout = useCheckout();
const t = useT();
@ -191,7 +497,10 @@ const SubmitBar: FC<{ loading: boolean }> = ({ loading }) => {
{' '}
</span>
<span className="text-textColor font-[600]">
{t('billing_cancel_anytime_short', 'Cancel anytime from settings')}
{t(
'billing_cancel_anytime_short',
'Cancel anytime from settings'
)}
</span>
</div>
) : null}

View File

@ -120,7 +120,7 @@ export const FAQSection: FC<{
onClick={(e) => {
e.stopPropagation();
}}
className={`mt-[16px] w-full text-wrap font-[400] text-[16px] text-customColor17 select-text`}
className={`mt-[16px] w-full text-wrap font-[400] text-[16px] text-customColor17 select-text max-w-[450px]`}
dangerouslySetInnerHTML={{
__html: description,
}}
@ -134,10 +134,10 @@ export const FAQComponent: FC = () => {
const list = useFaqList();
return (
<div>
<h3 className="text-[24px] mt-[48px] mb-[40px] tablet:mt-[80px]">
{t('frequently_asked_questions', 'Frequently Asked Questions')}
</h3>
<div className="gap-[24px] flex-col flex select-none">
{/*<h3 className="text-[24px] mt-[48px] mb-[40px] tablet:mt-[80px]">*/}
{/* {t('frequently_asked_questions', 'Frequently Asked Questions')}*/}
{/*</h3>*/}
<div className="gap-[24px] flex-col flex select-none mt-[48px] mb-[40px] tablet:mt-[80px]">
{list.map((item, index) => (
<FAQSection key={index} {...item} />
))}

View File

@ -160,10 +160,12 @@ export const FirstBillingComponent = () => {
<JoinOver />
</div>
{!isLoading && data && stripe ? (
<>
<EmbeddedBilling stripe={stripe} secret={data.client_secret} />
<FAQComponent />
</>
<EmbeddedBilling
stripe={stripe}
secret={data.client_secret}
showCoupon={period === 'MONTHLY'}
autoApplyCoupon={data.auto_apply_coupon}
/>
) : (
<LoadingComponent />
)}
@ -245,6 +247,10 @@ export const FirstBillingComponent = () => {
</div>
<BillingFeatures tier={tier} />
</div>
<div className="flex flex-col mobile:hidden tablet:hidden">
{/*<div>asd</div>*/}
<FAQComponent />
</div>
</div>
</div>
</div>

View File

@ -346,6 +346,54 @@ export class StripeService {
});
}
/**
* Find an active promotion code with autoapply: true metadata
* Only returns codes that are active and not expired
* Returns the promotion code string (not the ID) for frontend auto-apply
*/
private async findAutoApplyPromotionCode(): Promise<string | null> {
try {
const promotionCodes = await stripe.promotionCodes.list({
active: true,
limit: 100,
});
const now = Math.floor(Date.now() / 1000);
for (const promoCode of promotionCodes.data) {
// Check if it has autoapply metadata set to true (check both promo and coupon metadata)
const autoApply = Object.assign(
{},
promoCode.metadata,
promoCode.coupon.metadata
)?.autoapply;
if (autoApply !== 'true') continue;
// Check if the promotion code has expired
if (promoCode.expires_at && promoCode.expires_at < now) continue;
// Check if the coupon has expired (redeem_by)
if (promoCode.coupon.redeem_by && promoCode.coupon.redeem_by < now)
continue;
// Check if max redemptions reached
if (
promoCode.max_redemptions &&
promoCode.times_redeemed >= promoCode.max_redemptions
)
continue;
// Found a valid auto-apply promotion code - return the code string for frontend
return promoCode.code;
}
return null;
} catch (err) {
console.error('Error finding auto-apply promotion code:', err);
return null;
}
}
private async createEmbeddedCheckout(
ud: string,
uniqueId: string,
@ -376,6 +424,12 @@ export class StripeService {
});
} catch (err) {}
// Check for auto-apply promotion code (only for monthly plans)
let autoApplyPromoCode: string | null = null;
if (body.period === 'MONTHLY') {
autoApplyPromoCode = await this.findAutoApplyPromotionCode();
}
const isUtm = body.utm ? `&utm_source=${body.utm}` : '';
// @ts-ignore
const { client_secret } = await stripeCustom.checkout.sessions.create({
@ -405,7 +459,11 @@ export class StripeService {
],
});
return { client_secret };
// Return auto-apply promo code for frontend to apply
return {
client_secret,
...(autoApplyPromoCode ? { auto_apply_coupon: autoApplyPromoCode } : {}),
};
}
private async createCheckoutSession(