Skip to content

Commit ba4ad5d

Browse files
committed
feat: stripe changes
1 parent 07b0c2e commit ba4ad5d

File tree

4 files changed

+395
-22
lines changed

4 files changed

+395
-22
lines changed

apps/frontend/src/components/billing/embedded.billing.tsx

Lines changed: 321 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import { useT } from '@gitroom/react/translation/get.transation.service.client';
2020
export 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+
169475
const 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

Comments
 (0)