diff --git a/apps/frontend/src/components/billing/embedded.billing.tsx b/apps/frontend/src/components/billing/embedded.billing.tsx index b0c26104..c556b890 100644 --- a/apps/frontend/src/components/billing/embedded.billing.tsx +++ b/apps/frontend/src/components/billing/embedded.billing.tsx @@ -20,7 +20,9 @@ import { useT } from '@gitroom/react/translation/get.transation.service.client'; export const EmbeddedBilling: FC<{ stripe: Promise; 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<{ }, }} > - + ); }; -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 (
- + ); }; -const StripeInputs = () => { +const StripeInputs: FC<{ showCoupon: boolean; autoApplyCoupon?: string }> = ({ + showCoupon, + autoApplyCoupon, +}) => { const checkout = useCheckout(); const t = useT(); + const [ready, setReady] = useState(false); return ( <>
@@ -135,14 +147,12 @@ const StripeInputs = () => {

{checkout.type === 'loading' ? '' : t('billing_payment', 'Payment')}

- + setReady(true)} /> + {showCoupon && ready && } {checkout.type === 'loading' ? null : (
- {t( - 'billing_powered_by_stripe', - 'Secure payments processed by' - )} + {t('billing_powered_by_stripe', 'Secure payments processed by')}
{ ); }; +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 ( +
+
+
+
+ + + + + {appliedCode} + + {t('billing_discount_applied', 'applied')} + {discountDisplay && ` (${discountDisplay})`} + +
+
+ +
+ {expirationDate && ( +

+ + + + + {t('billing_coupon_expires', 'Coupon expires on')} {expirationDate} +

+ )} +
+ ); +}; + +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(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 ( +
+ +
+ ); + } + + // Show "Have a promo code?" link + if (!showInput) { + return ( +
+ +
+ ); + } + + // Show input field + return ( +
+
+

+ {t('billing_discount_coupon', 'Discount Coupon')} +

+ +
+
+ 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(''); + } + }} + /> + +
+
+ ); +}; + const SubmitBar: FC<{ loading: boolean }> = ({ loading }) => { const checkout = useCheckout(); const t = useT(); @@ -191,7 +497,10 @@ const SubmitBar: FC<{ loading: boolean }> = ({ loading }) => { —{' '} - {t('billing_cancel_anytime_short', 'Cancel anytime from settings')} + {t( + 'billing_cancel_anytime_short', + 'Cancel anytime from settings' + )}
) : null} diff --git a/apps/frontend/src/components/billing/faq.component.tsx b/apps/frontend/src/components/billing/faq.component.tsx index b5eac193..bd5fceb2 100644 --- a/apps/frontend/src/components/billing/faq.component.tsx +++ b/apps/frontend/src/components/billing/faq.component.tsx @@ -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 (
-

- {t('frequently_asked_questions', 'Frequently Asked Questions')} -

-
+ {/*

*/} + {/* {t('frequently_asked_questions', 'Frequently Asked Questions')}*/} + {/*

*/} +
{list.map((item, index) => ( ))} diff --git a/apps/frontend/src/components/billing/first.billing.component.tsx b/apps/frontend/src/components/billing/first.billing.component.tsx index 2cda0593..8e18bbdf 100644 --- a/apps/frontend/src/components/billing/first.billing.component.tsx +++ b/apps/frontend/src/components/billing/first.billing.component.tsx @@ -160,10 +160,12 @@ export const FirstBillingComponent = () => {
{!isLoading && data && stripe ? ( - <> - - - + ) : ( )} @@ -245,6 +247,10 @@ export const FirstBillingComponent = () => {
+
+ {/*
asd
*/} + +
diff --git a/libraries/nestjs-libraries/src/services/stripe.service.ts b/libraries/nestjs-libraries/src/services/stripe.service.ts index 6462b317..4a9bbb88 100644 --- a/libraries/nestjs-libraries/src/services/stripe.service.ts +++ b/libraries/nestjs-libraries/src/services/stripe.service.ts @@ -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 { + 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(