@@ -20,7 +20,9 @@ import { useT } from '@gitroom/react/translation/get.transation.service.client';
2020export const EmbeddedBilling : FC < {
2121 stripe : Promise < Stripe > ;
2222 secret : string ;
23- } > = ( { stripe, secret } ) => {
23+ showCoupon ?: boolean ;
24+ autoApplyCoupon ?: string ;
25+ } > = ( { stripe, secret, showCoupon = false , autoApplyCoupon } ) => {
2426 const [ saveSecret , setSaveSecret ] = useState ( secret ) ;
2527 const [ loading , setLoading ] = useState ( false ) ;
2628 const [ mode , setMode ] = useCookie ( 'mode' , 'dark' ) ;
@@ -80,18 +82,24 @@ export const EmbeddedBilling: FC<{
8082 } ,
8183 } }
8284 >
83- < FormWrapper />
85+ < FormWrapper
86+ showCoupon = { showCoupon }
87+ autoApplyCoupon = { autoApplyCoupon }
88+ />
8489 </ CheckoutProvider >
8590 </ div >
8691 ) ;
8792} ;
8893
89- const FormWrapper = ( ) => {
94+ const FormWrapper : FC < { showCoupon ?: boolean ; autoApplyCoupon ?: string } > = ( {
95+ showCoupon = false ,
96+ autoApplyCoupon,
97+ } ) => {
9098 const checkoutState = useCheckout ( ) ;
9199 const toaster = useToaster ( ) ;
92100 const [ loading , setLoading ] = useState ( false ) ;
93101
94- if ( checkoutState . type === 'loading' || checkoutState . type === 'error ') {
102+ if ( checkoutState . type !== 'success ') {
95103 return null ;
96104 }
97105
@@ -112,15 +120,19 @@ const FormWrapper = () => {
112120
113121 return (
114122 < form onSubmit = { handleSubmit } className = "flex flex-col flex-1" >
115- < StripeInputs />
123+ < StripeInputs showCoupon = { showCoupon } autoApplyCoupon = { autoApplyCoupon } />
116124 < SubmitBar loading = { loading } />
117125 </ form >
118126 ) ;
119127} ;
120128
121- const StripeInputs = ( ) => {
129+ const StripeInputs : FC < { showCoupon : boolean ; autoApplyCoupon ?: string } > = ( {
130+ showCoupon,
131+ autoApplyCoupon,
132+ } ) => {
122133 const checkout = useCheckout ( ) ;
123134 const t = useT ( ) ;
135+ const [ ready , setReady ] = useState ( false ) ;
124136 return (
125137 < >
126138 < div >
@@ -135,14 +147,12 @@ const StripeInputs = () => {
135147 < h4 className = "mt-[40px] mb-[32px] text-[24px] font-[700]" >
136148 { checkout . type === 'loading' ? '' : t ( 'billing_payment' , 'Payment' ) }
137149 </ h4 >
138- < PaymentElement id = "payment-element" options = { { layout : 'tabs' } } />
150+ < PaymentElement id = "payment-element" options = { { layout : 'tabs' } } onReady = { ( ) => setReady ( true ) } />
151+ { showCoupon && ready && < CouponInput autoApplyCoupon = { autoApplyCoupon } /> }
139152 { checkout . type === 'loading' ? null : (
140153 < div className = "mt-[24px] text-[16px] font-[600] flex gap-[4px] items-center" >
141154 < div >
142- { t (
143- 'billing_powered_by_stripe' ,
144- 'Secure payments processed by'
145- ) }
155+ { t ( 'billing_powered_by_stripe' , 'Secure payments processed by' ) }
146156 </ div >
147157 < svg
148158 className = "mt-[4px]"
@@ -166,6 +176,302 @@ const StripeInputs = () => {
166176 ) ;
167177} ;
168178
179+ const AppliedCouponDisplay : FC < {
180+ appliedCode : string ;
181+ checkout : any ;
182+ isApplying : boolean ;
183+ onRemove : ( ) => void ;
184+ } > = ( { appliedCode, checkout, isApplying, onRemove } ) => {
185+ const t = useT ( ) ;
186+
187+ // Get discount display from checkout state
188+ const getDiscountDisplay = ( ) : string | null => {
189+ // Try to get percentage from discountAmounts
190+ const percentOff = checkout ?. discountAmounts ?. [ 0 ] ?. percentOff ;
191+ if ( percentOff && typeof percentOff === 'number' && percentOff > 0 ) {
192+ return `-${ percentOff } %` ;
193+ }
194+
195+ // Try to get actual discount amount from recurring.dueNext.discount
196+ const recurringDiscount =
197+ checkout ?. recurring ?. dueNext ?. discount ?. minorUnitsAmount ;
198+ if (
199+ recurringDiscount &&
200+ typeof recurringDiscount === 'number' &&
201+ recurringDiscount > 0
202+ ) {
203+ return `-$${ ( recurringDiscount / 100 ) . toFixed ( 2 ) } ` ;
204+ }
205+
206+ // Try lineItems discount
207+ const lineItemDiscount =
208+ checkout ?. lineItems ?. [ 0 ] ?. discountAmounts ?. [ 0 ] ?. percentOff ;
209+ if (
210+ lineItemDiscount &&
211+ typeof lineItemDiscount === 'number' &&
212+ lineItemDiscount > 0
213+ ) {
214+ return `-${ lineItemDiscount } %` ;
215+ }
216+
217+ return null ;
218+ } ;
219+
220+ // Get expiration date from checkout state (if available)
221+ const getExpirationDate = ( ) : string | null => {
222+ const discount = checkout ?. discountAmounts ?. [ 0 ] ;
223+ const lineItemDiscount = checkout ?. lineItems ?. [ 0 ] ?. discountAmounts ?. [ 0 ] ;
224+
225+ // Check for expiresAt in various locations (Unix timestamp)
226+ const expiresAt =
227+ discount ?. expiresAt ||
228+ discount ?. expires_at ||
229+ lineItemDiscount ?. expiresAt ||
230+ lineItemDiscount ?. expires_at ||
231+ checkout ?. promotionCode ?. expiresAt ||
232+ checkout ?. promotionCode ?. expires_at ;
233+
234+ if ( expiresAt && typeof expiresAt === 'number' ) {
235+ const date = new Date ( expiresAt * 1000 ) ;
236+ return dayjs ( date ) . format ( 'MMMM D, YYYY' ) ;
237+ }
238+
239+ if ( expiresAt && typeof expiresAt === 'string' ) {
240+ return dayjs ( expiresAt ) . format ( 'MMMM D, YYYY' ) ;
241+ }
242+
243+ return null ;
244+ } ;
245+
246+ const discountDisplay = getDiscountDisplay ( ) ;
247+ const expirationDate = getExpirationDate ( ) ;
248+
249+ return (
250+ < div className = "flex flex-col gap-[8px]" >
251+ < div className = "flex items-center gap-[12px] p-[16px] rounded-[12px] border border-[#AA0FA4]/30 bg-[#AA0FA4]/10" >
252+ < div className = "flex-1" >
253+ < div className = "flex items-center gap-[8px] flex-wrap" >
254+ < svg
255+ xmlns = "http://www.w3.org/2000/svg"
256+ width = "20"
257+ height = "20"
258+ viewBox = "0 0 24 24"
259+ fill = "none"
260+ stroke = "#FC69FF"
261+ strokeWidth = "2"
262+ strokeLinecap = "round"
263+ strokeLinejoin = "round"
264+ >
265+ < path d = "M22 11.08V12a10 10 0 1 1-5.93-9.14" />
266+ < polyline points = "22 4 12 14.01 9 11.01" />
267+ </ svg >
268+ < span className = "font-[600] text-[#FC69FF]" > { appliedCode } </ span >
269+ < span className = "text-[14px] text-textColor/70" >
270+ { t ( 'billing_discount_applied' , 'applied' ) }
271+ { discountDisplay && ` (${ discountDisplay } )` }
272+ </ span >
273+ </ div >
274+ </ div >
275+ < button
276+ type = "button"
277+ onClick = { onRemove }
278+ disabled = { isApplying }
279+ className = "text-[14px] text-textColor/50 hover:text-textColor font-[500] disabled:opacity-50"
280+ >
281+ { t ( 'billing_remove' , 'Remove' ) }
282+ </ button >
283+ </ div >
284+ { expirationDate && (
285+ < p className = "text-[13px] text-textColor/50 flex items-center gap-[6px]" >
286+ < svg
287+ xmlns = "http://www.w3.org/2000/svg"
288+ width = "14"
289+ height = "14"
290+ viewBox = "0 0 24 24"
291+ fill = "none"
292+ stroke = "currentColor"
293+ strokeWidth = "2"
294+ strokeLinecap = "round"
295+ strokeLinejoin = "round"
296+ >
297+ < circle cx = "12" cy = "12" r = "10" />
298+ < polyline points = "12 6 12 12 16 14" />
299+ </ svg >
300+ { t ( 'billing_coupon_expires' , 'Coupon expires on' ) } { expirationDate }
301+ </ p >
302+ ) }
303+ </ div >
304+ ) ;
305+ } ;
306+
307+ export const CouponInput : FC < { autoApplyCoupon ?: string } > = ( {
308+ autoApplyCoupon,
309+ } ) => {
310+ const checkoutState = useCheckout ( ) ;
311+ const t = useT ( ) ;
312+ const toaster = useToaster ( ) ;
313+ const [ couponCode , setCouponCode ] = useState ( '' ) ;
314+ const [ isApplying , setIsApplying ] = useState ( false ) ;
315+ const [ appliedCode , setAppliedCode ] = useState < string | null > ( null ) ;
316+ const [ showInput , setShowInput ] = useState ( false ) ;
317+
318+ const { checkout } =
319+ checkoutState . type === 'success' ? checkoutState : { checkout : null } ;
320+
321+ // Auto-apply coupon from backend when checkout is ready
322+ useEffect ( ( ) => {
323+ if ( autoApplyCoupon ) {
324+ handleApplyCoupon ( undefined , autoApplyCoupon ) ;
325+ }
326+ } , [ ] ) ;
327+
328+ // Check if a coupon is already pre-applied (e.g., auto-apply coupon from backend)
329+ const preAppliedCode = checkout ?. discountAmounts ?. [ 0 ] ?. promotionCode ;
330+ const effectiveAppliedCode = appliedCode || preAppliedCode || null ;
331+
332+ const handleApplyCoupon = async ( e ?: any , coupon ?: string ) => {
333+ if ( ! coupon && ! couponCode . trim ( ) ) return ;
334+
335+ setIsApplying ( true ) ;
336+ try {
337+ const result = await checkout . applyPromotionCode (
338+ coupon || couponCode . trim ( )
339+ ) ;
340+ if ( result . type === 'error' ) {
341+ toaster . show (
342+ result . error . message ||
343+ t ( 'billing_invalid_coupon' , 'Invalid coupon code' ) ,
344+ 'warning'
345+ ) ;
346+ } else {
347+ setAppliedCode ( coupon || couponCode . trim ( ) ) ;
348+ setCouponCode ( '' ) ;
349+ setShowInput ( false ) ;
350+ toaster . show (
351+ t ( 'billing_coupon_applied' , 'Coupon applied successfully!' ) ,
352+ 'success'
353+ ) ;
354+ }
355+ } catch ( err : any ) {
356+ toaster . show (
357+ err . message || t ( 'billing_invalid_coupon' , 'Invalid coupon code' ) ,
358+ 'warning'
359+ ) ;
360+ }
361+ setIsApplying ( false ) ;
362+ } ;
363+
364+ const handleRemoveCoupon = async ( ) => {
365+ setIsApplying ( true ) ;
366+ try {
367+ await checkout . removePromotionCode ( ) ;
368+ setAppliedCode ( null ) ;
369+ toaster . show ( t ( 'billing_coupon_removed' , 'Coupon removed' ) , 'success' ) ;
370+ } catch ( err : any ) {
371+ toaster . show (
372+ err . message ||
373+ t ( 'billing_error_removing_coupon' , 'Error removing coupon' ) ,
374+ 'warning'
375+ ) ;
376+ }
377+ setIsApplying ( false ) ;
378+ } ;
379+
380+ // Show applied coupon (either manually applied or pre-applied from backend)
381+ if ( effectiveAppliedCode ) {
382+ return (
383+ < div className = "mt-[40px]" >
384+ < AppliedCouponDisplay
385+ appliedCode = { effectiveAppliedCode }
386+ checkout = { checkout }
387+ isApplying = { isApplying }
388+ onRemove = { handleRemoveCoupon }
389+ />
390+ </ div >
391+ ) ;
392+ }
393+
394+ // Show "Have a promo code?" link
395+ if ( ! showInput ) {
396+ return (
397+ < div className = "mt-[40px]" >
398+ < button
399+ type = "button"
400+ onClick = { ( ) => setShowInput ( true ) }
401+ className = "text-[16px] text-textColor/60 hover:text-textColor font-[500] flex items-center gap-[8px] transition-colors"
402+ >
403+ < svg
404+ xmlns = "http://www.w3.org/2000/svg"
405+ width = "18"
406+ height = "18"
407+ viewBox = "0 0 24 24"
408+ fill = "none"
409+ stroke = "currentColor"
410+ strokeWidth = "2"
411+ strokeLinecap = "round"
412+ strokeLinejoin = "round"
413+ >
414+ < 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" />
415+ </ svg >
416+ { t ( 'billing_have_discount_coupon' , 'Have a discount coupon?' ) }
417+ </ button >
418+ </ div >
419+ ) ;
420+ }
421+
422+ // Show input field
423+ return (
424+ < div className = "mt-[40px]" >
425+ < div className = "flex items-center gap-[12px] mb-[12px]" >
426+ < h4 className = "text-[18px] font-[600] text-textColor" >
427+ { t ( 'billing_discount_coupon' , 'Discount Coupon' ) }
428+ </ h4 >
429+ < button
430+ type = "button"
431+ onClick = { ( ) => {
432+ setShowInput ( false ) ;
433+ setCouponCode ( '' ) ;
434+ } }
435+ className = "text-[14px] text-textColor/50 hover:text-textColor transition-colors"
436+ >
437+ { t ( 'billing_cancel' , 'Cancel' ) }
438+ </ button >
439+ </ div >
440+ < div className = "flex gap-[12px]" >
441+ < input
442+ type = "text"
443+ value = { couponCode }
444+ onChange = { ( e ) => setCouponCode ( e . target . value ) }
445+ placeholder = { t ( 'billing_enter_coupon_code' , 'Enter coupon code' ) }
446+ disabled = { isApplying }
447+ autoFocus
448+ 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"
449+ onKeyDown = { ( e ) => {
450+ if ( e . key === 'Enter' ) {
451+ e . preventDefault ( ) ;
452+ handleApplyCoupon ( ) ;
453+ }
454+ if ( e . key === 'Escape' ) {
455+ setShowInput ( false ) ;
456+ setCouponCode ( '' ) ;
457+ }
458+ } }
459+ />
460+ < button
461+ type = "button"
462+ onClick = { ( ) => handleApplyCoupon ( ) }
463+ disabled = { isApplying || ! couponCode . trim ( ) }
464+ 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"
465+ >
466+ { isApplying
467+ ? t ( 'billing_applying' , 'Applying...' )
468+ : t ( 'billing_apply' , 'Apply' ) }
469+ </ button >
470+ </ div >
471+ </ div >
472+ ) ;
473+ } ;
474+
169475const SubmitBar : FC < { loading : boolean } > = ( { loading } ) => {
170476 const checkout = useCheckout ( ) ;
171477 const t = useT ( ) ;
@@ -191,7 +497,10 @@ const SubmitBar: FC<{ loading: boolean }> = ({ loading }) => {
191497 —{ ' ' }
192498 </ span >
193499 < span className = "text-textColor font-[600]" >
194- { t ( 'billing_cancel_anytime_short' , 'Cancel anytime from settings' ) }
500+ { t (
501+ 'billing_cancel_anytime_short' ,
502+ 'Cancel anytime from settings'
503+ ) }
195504 </ span >
196505 </ div >
197506 ) : null }
0 commit comments