STripe Paymen and PK payemtns and many othe rbacekd and froentened issues
This commit is contained in:
@@ -214,6 +214,7 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
|||||||
'bulk_add_credits',
|
'bulk_add_credits',
|
||||||
'bulk_subtract_credits',
|
'bulk_subtract_credits',
|
||||||
'bulk_soft_delete',
|
'bulk_soft_delete',
|
||||||
|
'bulk_hard_delete',
|
||||||
]
|
]
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
@@ -454,14 +455,32 @@ class AccountAdmin(ExportMixin, AccountAdminMixin, SimpleHistoryAdmin, Igny8Mode
|
|||||||
bulk_subtract_credits.short_description = 'Subtract credits from accounts'
|
bulk_subtract_credits.short_description = 'Subtract credits from accounts'
|
||||||
|
|
||||||
def bulk_soft_delete(self, request, queryset):
|
def bulk_soft_delete(self, request, queryset):
|
||||||
"""Soft delete selected accounts"""
|
"""Soft delete selected accounts and all related data"""
|
||||||
count = 0
|
count = 0
|
||||||
for account in queryset:
|
for account in queryset:
|
||||||
if account.slug != 'aws-admin': # Protect admin account
|
if account.slug != 'aws-admin': # Protect admin account
|
||||||
account.delete() # Soft delete via SoftDeletableModel
|
account.delete() # Soft delete via SoftDeletableModel (now cascades)
|
||||||
count += 1
|
count += 1
|
||||||
self.message_user(request, f'{count} account(s) soft deleted.', messages.SUCCESS)
|
self.message_user(request, f'{count} account(s) and all related data soft deleted.', messages.SUCCESS)
|
||||||
bulk_soft_delete.short_description = 'Soft delete selected accounts'
|
bulk_soft_delete.short_description = 'Soft delete accounts (with cascade)'
|
||||||
|
|
||||||
|
def bulk_hard_delete(self, request, queryset):
|
||||||
|
"""PERMANENTLY delete selected accounts and ALL related data - cannot be undone!"""
|
||||||
|
count = 0
|
||||||
|
errors = []
|
||||||
|
for account in queryset:
|
||||||
|
if account.slug != 'aws-admin': # Protect admin account
|
||||||
|
try:
|
||||||
|
account.hard_delete_with_cascade() # Permanently delete everything
|
||||||
|
count += 1
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f'{account.name}: {str(e)}')
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
self.message_user(request, f'{count} account(s) and ALL related data permanently deleted.', messages.SUCCESS)
|
||||||
|
if errors:
|
||||||
|
self.message_user(request, f'Errors: {"; ".join(errors)}', messages.ERROR)
|
||||||
|
bulk_hard_delete.short_description = '⚠️ PERMANENTLY delete accounts (irreversible!)'
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionResource(resources.ModelResource):
|
class SubscriptionResource(resources.ModelResource):
|
||||||
|
|||||||
@@ -153,12 +153,144 @@ class Account(SoftDeletableModel):
|
|||||||
# System accounts bypass all filtering restrictions
|
# System accounts bypass all filtering restrictions
|
||||||
return self.slug in ['aws-admin', 'default-account', 'default']
|
return self.slug in ['aws-admin', 'default-account', 'default']
|
||||||
|
|
||||||
def soft_delete(self, user=None, reason=None, retention_days=None):
|
def soft_delete(self, user=None, reason=None, retention_days=None, cascade=True):
|
||||||
|
"""
|
||||||
|
Soft delete the account and optionally cascade to all related objects.
|
||||||
|
Args:
|
||||||
|
user: User performing the deletion
|
||||||
|
reason: Reason for deletion
|
||||||
|
retention_days: Days before permanent deletion
|
||||||
|
cascade: If True, also soft-delete related objects that support soft delete,
|
||||||
|
and hard-delete objects that don't support soft delete
|
||||||
|
"""
|
||||||
if self.is_system_account():
|
if self.is_system_account():
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
raise PermissionDenied("System account cannot be deleted.")
|
raise PermissionDenied("System account cannot be deleted.")
|
||||||
|
|
||||||
|
if cascade:
|
||||||
|
self._cascade_delete_related(user=user, reason=reason, retention_days=retention_days, hard_delete=False)
|
||||||
|
|
||||||
return super().soft_delete(user=user, reason=reason, retention_days=retention_days)
|
return super().soft_delete(user=user, reason=reason, retention_days=retention_days)
|
||||||
|
|
||||||
|
def _cascade_delete_related(self, user=None, reason=None, retention_days=None, hard_delete=False):
|
||||||
|
"""
|
||||||
|
Delete all related objects when account is deleted.
|
||||||
|
For soft delete: soft-deletes objects with SoftDeletableModel, hard-deletes others
|
||||||
|
For hard delete: hard-deletes everything
|
||||||
|
"""
|
||||||
|
from igny8_core.common.soft_delete import SoftDeletableModel
|
||||||
|
|
||||||
|
# List of related objects to delete (in order to avoid FK constraint issues)
|
||||||
|
# Related names from Account reverse relations
|
||||||
|
related_names = [
|
||||||
|
# Content & Planning related (delete first due to dependencies)
|
||||||
|
'contentclustermap_set',
|
||||||
|
'contentattribute_set',
|
||||||
|
'contenttaxonomy_set',
|
||||||
|
'content_set',
|
||||||
|
'images_set',
|
||||||
|
'contentideas_set',
|
||||||
|
'tasks_set',
|
||||||
|
'keywords_set',
|
||||||
|
'clusters_set',
|
||||||
|
'strategy_set',
|
||||||
|
# Automation
|
||||||
|
'automation_runs',
|
||||||
|
'automation_configs',
|
||||||
|
# Publishing & Integration
|
||||||
|
'syncevent_set',
|
||||||
|
'publishingsettings_set',
|
||||||
|
'publishingrecord_set',
|
||||||
|
'deploymentrecord_set',
|
||||||
|
'siteintegration_set',
|
||||||
|
# Notifications & Optimization
|
||||||
|
'notification_set',
|
||||||
|
'optimizationtask_set',
|
||||||
|
# AI & Settings
|
||||||
|
'aitasklog_set',
|
||||||
|
'aiprompt_set',
|
||||||
|
'aisettings_set',
|
||||||
|
'authorprofile_set',
|
||||||
|
# Billing (preserve invoices/payments for audit, delete others)
|
||||||
|
'planlimitusage_set',
|
||||||
|
'creditusagelog_set',
|
||||||
|
'credittransaction_set',
|
||||||
|
'accountpaymentmethod_set',
|
||||||
|
'payment_set',
|
||||||
|
'invoice_set',
|
||||||
|
# Settings
|
||||||
|
'modulesettings_set',
|
||||||
|
'moduleenablesettings_set',
|
||||||
|
'integrationsettings_set',
|
||||||
|
'user_settings',
|
||||||
|
'accountsettings_set',
|
||||||
|
# Core (last due to dependencies)
|
||||||
|
'sector_set',
|
||||||
|
'site_set',
|
||||||
|
# Subscription (OneToOne)
|
||||||
|
'subscription',
|
||||||
|
]
|
||||||
|
|
||||||
|
for related_name in related_names:
|
||||||
|
try:
|
||||||
|
related = getattr(self, related_name, None)
|
||||||
|
if related is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle OneToOne fields (subscription)
|
||||||
|
if hasattr(related, 'pk'):
|
||||||
|
# It's a single object (OneToOneField)
|
||||||
|
if hard_delete:
|
||||||
|
related.hard_delete() if hasattr(related, 'hard_delete') else related.delete()
|
||||||
|
elif isinstance(related, SoftDeletableModel):
|
||||||
|
related.soft_delete(user=user, reason=reason, retention_days=retention_days)
|
||||||
|
else:
|
||||||
|
# Non-soft-deletable single object - hard delete
|
||||||
|
related.delete()
|
||||||
|
else:
|
||||||
|
# It's a RelatedManager (ForeignKey)
|
||||||
|
queryset = related.all()
|
||||||
|
if queryset.exists():
|
||||||
|
if hard_delete:
|
||||||
|
# Hard delete all
|
||||||
|
if hasattr(queryset, 'hard_delete'):
|
||||||
|
queryset.hard_delete()
|
||||||
|
else:
|
||||||
|
for obj in queryset:
|
||||||
|
if hasattr(obj, 'hard_delete'):
|
||||||
|
obj.hard_delete()
|
||||||
|
else:
|
||||||
|
obj.delete()
|
||||||
|
else:
|
||||||
|
# Soft delete if supported, otherwise hard delete
|
||||||
|
model = queryset.model
|
||||||
|
if issubclass(model, SoftDeletableModel):
|
||||||
|
for obj in queryset:
|
||||||
|
obj.soft_delete(user=user, reason=reason, retention_days=retention_days)
|
||||||
|
else:
|
||||||
|
queryset.delete()
|
||||||
|
except Exception as e:
|
||||||
|
# Log but don't fail - some relations may not exist
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.warning(f"Failed to delete related {related_name} for account {self.pk}: {e}")
|
||||||
|
|
||||||
|
def hard_delete_with_cascade(self, using=None, keep_parents=False):
|
||||||
|
"""
|
||||||
|
Permanently delete the account and ALL related objects.
|
||||||
|
This bypasses soft-delete and removes everything from the database.
|
||||||
|
USE WITH CAUTION - this cannot be undone!
|
||||||
|
"""
|
||||||
|
if self.is_system_account():
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
raise PermissionDenied("System account cannot be deleted.")
|
||||||
|
|
||||||
|
# Cascade hard-delete all related objects first
|
||||||
|
self._cascade_delete_related(hard_delete=True)
|
||||||
|
|
||||||
|
# Finally hard-delete the account itself
|
||||||
|
return super().hard_delete(using=using, keep_parents=keep_parents)
|
||||||
|
|
||||||
def delete(self, using=None, keep_parents=False):
|
def delete(self, using=None, keep_parents=False):
|
||||||
return self.soft_delete()
|
return self.soft_delete()
|
||||||
|
|
||||||
|
|||||||
@@ -406,11 +406,20 @@ class RegisterSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Generate unique slug for account
|
# Generate unique slug for account
|
||||||
base_slug = account_name.lower().replace(' ', '-').replace('_', '-')[:50] or 'account'
|
# Clean the base slug: lowercase, replace spaces and underscores with hyphens
|
||||||
slug = base_slug
|
import re
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
base_slug = re.sub(r'[^a-z0-9-]', '', account_name.lower().replace(' ', '-').replace('_', '-'))[:40] or 'account'
|
||||||
|
|
||||||
|
# Add random suffix to prevent collisions (especially during concurrent registrations)
|
||||||
|
random_suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
||||||
|
slug = f"{base_slug}-{random_suffix}"
|
||||||
|
|
||||||
|
# Ensure uniqueness with fallback counter
|
||||||
counter = 1
|
counter = 1
|
||||||
while Account.objects.filter(slug=slug).exists():
|
while Account.objects.filter(slug=slug).exists():
|
||||||
slug = f"{base_slug}-{counter}"
|
slug = f"{base_slug}-{random_suffix}-{counter}"
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|
||||||
# Create account with status and credits seeded (0 for paid pending)
|
# Create account with status and credits seeded (0 for paid pending)
|
||||||
|
|||||||
@@ -109,8 +109,9 @@ class RegisterView(APIView):
|
|||||||
refresh_expires_at = timezone.now() + get_refresh_token_expiry()
|
refresh_expires_at = timezone.now() + get_refresh_token_expiry()
|
||||||
|
|
||||||
user_serializer = UserSerializer(user)
|
user_serializer = UserSerializer(user)
|
||||||
return success_response(
|
|
||||||
data={
|
# Build response data
|
||||||
|
response_data = {
|
||||||
'user': user_serializer.data,
|
'user': user_serializer.data,
|
||||||
'tokens': {
|
'tokens': {
|
||||||
'access': access_token,
|
'access': access_token,
|
||||||
@@ -118,7 +119,60 @@ class RegisterView(APIView):
|
|||||||
'access_expires_at': access_expires_at.isoformat(),
|
'access_expires_at': access_expires_at.isoformat(),
|
||||||
'refresh_expires_at': refresh_expires_at.isoformat(),
|
'refresh_expires_at': refresh_expires_at.isoformat(),
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
|
# For Stripe payment method with paid plan, create checkout session
|
||||||
|
payment_method = request.data.get('payment_method', '')
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.info(f"Registration: payment_method={payment_method}, account_status={account.status if account else 'no account'}")
|
||||||
|
|
||||||
|
if account and account.status == 'pending_payment' and payment_method == 'stripe':
|
||||||
|
try:
|
||||||
|
from igny8_core.business.billing.services.stripe_service import StripeService
|
||||||
|
stripe_service = StripeService()
|
||||||
|
logger.info(f"Creating Stripe checkout for account {account.id}, plan {account.plan.name}")
|
||||||
|
checkout_data = stripe_service.create_checkout_session(
|
||||||
|
account=account,
|
||||||
|
plan=account.plan,
|
||||||
|
)
|
||||||
|
logger.info(f"Stripe checkout created: {checkout_data}")
|
||||||
|
response_data['checkout_url'] = checkout_data.get('checkout_url')
|
||||||
|
response_data['checkout_session_id'] = checkout_data.get('session_id')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create Stripe checkout session: {e}", exc_info=True)
|
||||||
|
# Don't fail registration, just log the error
|
||||||
|
# User can still complete payment from the plans page
|
||||||
|
|
||||||
|
# For PayPal payment method with paid plan, create PayPal order
|
||||||
|
elif account and account.status == 'pending_payment' and payment_method == 'paypal':
|
||||||
|
try:
|
||||||
|
from django.conf import settings
|
||||||
|
from igny8_core.business.billing.services.paypal_service import PayPalService
|
||||||
|
paypal_service = PayPalService()
|
||||||
|
frontend_url = getattr(settings, 'FRONTEND_URL', 'https://app.igny8.com')
|
||||||
|
|
||||||
|
logger.info(f"Creating PayPal order for account {account.id}, amount {account.plan.price}")
|
||||||
|
order = paypal_service.create_order(
|
||||||
|
account=account,
|
||||||
|
amount=float(account.plan.price),
|
||||||
|
description=f'{account.plan.name} Plan Subscription',
|
||||||
|
return_url=f'{frontend_url}/account/plans?paypal=success&plan_id={account.plan.id}',
|
||||||
|
cancel_url=f'{frontend_url}/account/plans?paypal=cancel',
|
||||||
|
metadata={
|
||||||
|
'plan_id': str(account.plan.id),
|
||||||
|
'type': 'subscription',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info(f"PayPal order created: {order}")
|
||||||
|
response_data['checkout_url'] = order.get('approval_url')
|
||||||
|
response_data['paypal_order_id'] = order.get('order_id')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create PayPal order: {e}", exc_info=True)
|
||||||
|
# Don't fail registration, just log the error
|
||||||
|
|
||||||
|
return success_response(
|
||||||
|
data=response_data,
|
||||||
message='Registration successful',
|
message='Registration successful',
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
request=request
|
request=request
|
||||||
|
|||||||
@@ -578,8 +578,10 @@ class CreditPackage(models.Model):
|
|||||||
|
|
||||||
class PaymentMethodConfig(models.Model):
|
class PaymentMethodConfig(models.Model):
|
||||||
"""
|
"""
|
||||||
Configure payment methods availability per country
|
Configure payment methods availability per country.
|
||||||
Allows enabling/disabling manual payments by region
|
|
||||||
|
For online payments (stripe, paypal): Credentials stored in IntegrationProvider.
|
||||||
|
For manual payments (bank_transfer, local_wallet): Bank/wallet details stored here.
|
||||||
"""
|
"""
|
||||||
# Use centralized choices
|
# Use centralized choices
|
||||||
PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES
|
PAYMENT_METHOD_CHOICES = PAYMENT_METHOD_CHOICES
|
||||||
@@ -587,7 +589,7 @@ class PaymentMethodConfig(models.Model):
|
|||||||
country_code = models.CharField(
|
country_code = models.CharField(
|
||||||
max_length=2,
|
max_length=2,
|
||||||
db_index=True,
|
db_index=True,
|
||||||
help_text="ISO 2-letter country code (e.g., US, GB, IN)"
|
help_text="ISO 2-letter country code (e.g., US, GB, PK) or '*' for global"
|
||||||
)
|
)
|
||||||
payment_method = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES)
|
payment_method = models.CharField(max_length=50, choices=PAYMENT_METHOD_CHOICES)
|
||||||
is_enabled = models.BooleanField(default=True)
|
is_enabled = models.BooleanField(default=True)
|
||||||
@@ -596,21 +598,17 @@ class PaymentMethodConfig(models.Model):
|
|||||||
display_name = models.CharField(max_length=100, blank=True)
|
display_name = models.CharField(max_length=100, blank=True)
|
||||||
instructions = models.TextField(blank=True, help_text="Payment instructions for users")
|
instructions = models.TextField(blank=True, help_text="Payment instructions for users")
|
||||||
|
|
||||||
# Manual payment details (for bank_transfer/local_wallet)
|
# Manual payment details (for bank_transfer only)
|
||||||
bank_name = models.CharField(max_length=255, blank=True)
|
bank_name = models.CharField(max_length=255, blank=True)
|
||||||
account_number = models.CharField(max_length=255, blank=True)
|
account_number = models.CharField(max_length=255, blank=True)
|
||||||
routing_number = models.CharField(max_length=255, blank=True)
|
account_title = models.CharField(max_length=255, blank=True, help_text="Account holder name")
|
||||||
swift_code = models.CharField(max_length=255, blank=True)
|
routing_number = models.CharField(max_length=255, blank=True, help_text="Routing/Sort code")
|
||||||
|
swift_code = models.CharField(max_length=255, blank=True, help_text="SWIFT/BIC code for international")
|
||||||
|
iban = models.CharField(max_length=255, blank=True, help_text="IBAN for international transfers")
|
||||||
|
|
||||||
# Additional fields for local wallets
|
# Additional fields for local wallets
|
||||||
wallet_type = models.CharField(max_length=100, blank=True, help_text="E.g., PayTM, PhonePe, etc.")
|
wallet_type = models.CharField(max_length=100, blank=True, help_text="E.g., JazzCash, EasyPaisa, etc.")
|
||||||
wallet_id = models.CharField(max_length=255, blank=True)
|
wallet_id = models.CharField(max_length=255, blank=True, help_text="Mobile number or wallet ID")
|
||||||
|
|
||||||
# Webhook configuration (Stripe/PayPal)
|
|
||||||
webhook_url = models.URLField(blank=True, help_text="Webhook URL for payment gateway callbacks")
|
|
||||||
webhook_secret = models.CharField(max_length=255, blank=True, help_text="Webhook secret for signature verification")
|
|
||||||
api_key = models.CharField(max_length=255, blank=True, help_text="API key for payment gateway integration")
|
|
||||||
api_secret = models.CharField(max_length=255, blank=True, help_text="API secret for payment gateway integration")
|
|
||||||
|
|
||||||
# Order/priority
|
# Order/priority
|
||||||
sort_order = models.IntegerField(default=0)
|
sort_order = models.IntegerField(default=0)
|
||||||
|
|||||||
@@ -14,6 +14,40 @@ from ....auth.models import Account, Subscription
|
|||||||
class InvoiceService:
|
class InvoiceService:
|
||||||
"""Service for managing invoices"""
|
"""Service for managing invoices"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_pending_invoice(subscription: Subscription) -> Optional[Invoice]:
|
||||||
|
"""
|
||||||
|
Get pending invoice for a subscription.
|
||||||
|
Used to find existing invoice during payment processing instead of creating duplicates.
|
||||||
|
"""
|
||||||
|
return Invoice.objects.filter(
|
||||||
|
subscription=subscription,
|
||||||
|
status='pending'
|
||||||
|
).order_by('-created_at').first()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_or_create_subscription_invoice(
|
||||||
|
subscription: Subscription,
|
||||||
|
billing_period_start: datetime,
|
||||||
|
billing_period_end: datetime
|
||||||
|
) -> tuple[Invoice, bool]:
|
||||||
|
"""
|
||||||
|
Get existing pending invoice or create new one.
|
||||||
|
Returns tuple of (invoice, created) where created is True if new invoice was created.
|
||||||
|
"""
|
||||||
|
# First try to find existing pending invoice for this subscription
|
||||||
|
existing = InvoiceService.get_pending_invoice(subscription)
|
||||||
|
if existing:
|
||||||
|
return existing, False
|
||||||
|
|
||||||
|
# Create new invoice if none exists
|
||||||
|
invoice = InvoiceService.create_subscription_invoice(
|
||||||
|
subscription=subscription,
|
||||||
|
billing_period_start=billing_period_start,
|
||||||
|
billing_period_end=billing_period_end
|
||||||
|
)
|
||||||
|
return invoice, True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def generate_invoice_number(account: Account) -> str:
|
def generate_invoice_number(account: Account) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -52,6 +86,10 @@ class InvoiceService:
|
|||||||
) -> Invoice:
|
) -> Invoice:
|
||||||
"""
|
"""
|
||||||
Create invoice for subscription billing period
|
Create invoice for subscription billing period
|
||||||
|
|
||||||
|
Currency logic:
|
||||||
|
- USD for online payments (stripe, paypal)
|
||||||
|
- Local currency (PKR) only for bank_transfer in applicable countries
|
||||||
"""
|
"""
|
||||||
account = subscription.account
|
account = subscription.account
|
||||||
plan = subscription.plan
|
plan = subscription.plan
|
||||||
@@ -74,11 +112,21 @@ class InvoiceService:
|
|||||||
invoice_date = timezone.now().date()
|
invoice_date = timezone.now().date()
|
||||||
due_date = invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET)
|
due_date = invoice_date + timedelta(days=INVOICE_DUE_DATE_OFFSET)
|
||||||
|
|
||||||
# Get currency based on billing country
|
# Determine currency based on payment method:
|
||||||
|
# - Online payments (stripe, paypal): Always USD
|
||||||
|
# - Manual payments (bank_transfer, local_wallet): Local currency for applicable countries
|
||||||
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
|
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
|
||||||
currency = get_currency_for_country(account.billing_country)
|
|
||||||
|
|
||||||
# Convert plan price to local currency
|
payment_method = account.payment_method
|
||||||
|
online_payment_methods = ['stripe', 'paypal']
|
||||||
|
|
||||||
|
if payment_method in online_payment_methods:
|
||||||
|
# Online payments are always in USD
|
||||||
|
currency = 'USD'
|
||||||
|
local_price = float(plan.price)
|
||||||
|
else:
|
||||||
|
# Manual payments use local currency for applicable countries
|
||||||
|
currency = get_currency_for_country(account.billing_country)
|
||||||
local_price = convert_usd_to_local(float(plan.price), account.billing_country)
|
local_price = convert_usd_to_local(float(plan.price), account.billing_country)
|
||||||
|
|
||||||
invoice = Invoice.objects.create(
|
invoice = Invoice.objects.create(
|
||||||
@@ -95,7 +143,8 @@ class InvoiceService:
|
|||||||
'billing_period_end': billing_period_end.isoformat(),
|
'billing_period_end': billing_period_end.isoformat(),
|
||||||
'subscription_id': subscription.id, # Keep in metadata for backward compatibility
|
'subscription_id': subscription.id, # Keep in metadata for backward compatibility
|
||||||
'usd_price': str(plan.price), # Store original USD price
|
'usd_price': str(plan.price), # Store original USD price
|
||||||
'exchange_rate': str(local_price / float(plan.price) if plan.price > 0 else 1.0)
|
'exchange_rate': str(local_price / float(plan.price) if plan.price > 0 else 1.0),
|
||||||
|
'payment_method': payment_method
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -120,15 +169,27 @@ class InvoiceService:
|
|||||||
) -> Invoice:
|
) -> Invoice:
|
||||||
"""
|
"""
|
||||||
Create invoice for credit package purchase
|
Create invoice for credit package purchase
|
||||||
|
|
||||||
|
Currency logic:
|
||||||
|
- USD for online payments (stripe, paypal)
|
||||||
|
- Local currency (PKR) only for bank_transfer in applicable countries
|
||||||
"""
|
"""
|
||||||
from igny8_core.business.billing.config import INVOICE_DUE_DATE_OFFSET
|
from igny8_core.business.billing.config import INVOICE_DUE_DATE_OFFSET
|
||||||
invoice_date = timezone.now().date()
|
invoice_date = timezone.now().date()
|
||||||
|
|
||||||
# Get currency based on billing country
|
# Determine currency based on payment method
|
||||||
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
|
from igny8_core.business.billing.utils.currency import get_currency_for_country, convert_usd_to_local
|
||||||
currency = get_currency_for_country(account.billing_country)
|
|
||||||
|
|
||||||
# Convert credit package price to local currency
|
payment_method = account.payment_method
|
||||||
|
online_payment_methods = ['stripe', 'paypal']
|
||||||
|
|
||||||
|
if payment_method in online_payment_methods:
|
||||||
|
# Online payments are always in USD
|
||||||
|
currency = 'USD'
|
||||||
|
local_price = float(credit_package.price)
|
||||||
|
else:
|
||||||
|
# Manual payments use local currency for applicable countries
|
||||||
|
currency = get_currency_for_country(account.billing_country)
|
||||||
local_price = convert_usd_to_local(float(credit_package.price), account.billing_country)
|
local_price = convert_usd_to_local(float(credit_package.price), account.billing_country)
|
||||||
|
|
||||||
invoice = Invoice.objects.create(
|
invoice = Invoice.objects.create(
|
||||||
@@ -143,7 +204,8 @@ class InvoiceService:
|
|||||||
'credit_package_id': credit_package.id,
|
'credit_package_id': credit_package.id,
|
||||||
'credit_amount': credit_package.credits,
|
'credit_amount': credit_package.credits,
|
||||||
'usd_price': str(credit_package.price), # Store original USD price
|
'usd_price': str(credit_package.price), # Store original USD price
|
||||||
'exchange_rate': str(local_price / float(credit_package.price) if credit_package.price > 0 else 1.0)
|
'exchange_rate': str(local_price / float(credit_package.price) if credit_package.price > 0 else 1.0),
|
||||||
|
'payment_method': payment_method
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -627,10 +627,9 @@ def _process_subscription_payment(account, plan_id: str, capture_result: dict) -
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create invoice
|
# Get existing pending invoice or create new one (avoids duplicates)
|
||||||
invoice = InvoiceService.create_subscription_invoice(
|
invoice, invoice_created = InvoiceService.get_or_create_subscription_invoice(
|
||||||
account=account,
|
subscription=subscription,
|
||||||
plan=plan,
|
|
||||||
billing_period_start=now,
|
billing_period_start=now,
|
||||||
billing_period_end=period_end,
|
billing_period_end=period_end,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Endpoints:
|
|||||||
"""
|
"""
|
||||||
import stripe
|
import stripe
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone as dt_timezone, timedelta
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
@@ -402,6 +402,7 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s
|
|||||||
# Get subscription details from Stripe
|
# Get subscription details from Stripe
|
||||||
try:
|
try:
|
||||||
stripe_sub = stripe.Subscription.retrieve(stripe_subscription_id)
|
stripe_sub = stripe.Subscription.retrieve(stripe_subscription_id)
|
||||||
|
logger.info(f"Retrieved Stripe subscription: {stripe_subscription_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to retrieve Stripe subscription {stripe_subscription_id}: {e}")
|
logger.error(f"Failed to retrieve Stripe subscription {stripe_subscription_id}: {e}")
|
||||||
return
|
return
|
||||||
@@ -413,6 +414,40 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s
|
|||||||
logger.error(f"Plan {plan_id} not found for subscription activation")
|
logger.error(f"Plan {plan_id} not found for subscription activation")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Extract period dates from Stripe subscription
|
||||||
|
# Note: In newer Stripe API, current_period_start/end are on subscription items, not the subscription
|
||||||
|
try:
|
||||||
|
# Convert to dict for easier access
|
||||||
|
sub_dict = stripe_sub.to_dict_recursive() if hasattr(stripe_sub, 'to_dict_recursive') else dict(stripe_sub)
|
||||||
|
|
||||||
|
# Try to get period from subscription items first (new API)
|
||||||
|
items = sub_dict.get('items', {}).get('data', [])
|
||||||
|
if items:
|
||||||
|
first_item = items[0]
|
||||||
|
period_start = first_item.get('current_period_start')
|
||||||
|
period_end = first_item.get('current_period_end')
|
||||||
|
else:
|
||||||
|
# Fallback to subscription level (old API)
|
||||||
|
period_start = sub_dict.get('current_period_start')
|
||||||
|
period_end = sub_dict.get('current_period_end')
|
||||||
|
|
||||||
|
# If still not found, use billing_cycle_anchor or start_date
|
||||||
|
if not period_start:
|
||||||
|
period_start = sub_dict.get('billing_cycle_anchor') or sub_dict.get('start_date') or sub_dict.get('created')
|
||||||
|
if not period_end:
|
||||||
|
# Default to 30 days from start
|
||||||
|
period_end = period_start + (30 * 24 * 60 * 60) if period_start else None
|
||||||
|
|
||||||
|
cancel_at_end = sub_dict.get('cancel_at_period_end', False)
|
||||||
|
|
||||||
|
logger.info(f"Extracted period: start={period_start}, end={period_end}, cancel_at_end={cancel_at_end}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to extract period dates from Stripe subscription: {e}")
|
||||||
|
# Use current time as fallback
|
||||||
|
period_start = timezone.now().timestamp()
|
||||||
|
period_end = (timezone.now() + timedelta(days=30)).timestamp()
|
||||||
|
cancel_at_end = False
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
# Create or update subscription
|
# Create or update subscription
|
||||||
subscription, created = Subscription.objects.update_or_create(
|
subscription, created = Subscription.objects.update_or_create(
|
||||||
@@ -422,28 +457,30 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s
|
|||||||
'stripe_subscription_id': stripe_subscription_id,
|
'stripe_subscription_id': stripe_subscription_id,
|
||||||
'status': 'active',
|
'status': 'active',
|
||||||
'current_period_start': datetime.fromtimestamp(
|
'current_period_start': datetime.fromtimestamp(
|
||||||
stripe_sub.current_period_start,
|
period_start,
|
||||||
tz=timezone.utc
|
tz=dt_timezone.utc
|
||||||
),
|
),
|
||||||
'current_period_end': datetime.fromtimestamp(
|
'current_period_end': datetime.fromtimestamp(
|
||||||
stripe_sub.current_period_end,
|
period_end,
|
||||||
tz=timezone.utc
|
tz=dt_timezone.utc
|
||||||
),
|
),
|
||||||
'cancel_at_period_end': stripe_sub.cancel_at_period_end,
|
'cancel_at_period_end': cancel_at_end,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create invoice record
|
# Get existing pending invoice or create new one (avoids duplicates)
|
||||||
amount = session.get('amount_total', 0) / 100 # Convert from cents
|
amount = session.get('amount_total', 0) / 100 # Convert from cents
|
||||||
currency = session.get('currency', 'usd').upper()
|
currency = session.get('currency', 'usd').upper()
|
||||||
|
|
||||||
invoice = InvoiceService.create_subscription_invoice(
|
invoice, invoice_created = InvoiceService.get_or_create_subscription_invoice(
|
||||||
account=account,
|
subscription=subscription,
|
||||||
plan=plan,
|
|
||||||
billing_period_start=subscription.current_period_start,
|
billing_period_start=subscription.current_period_start,
|
||||||
billing_period_end=subscription.current_period_end,
|
billing_period_end=subscription.current_period_end,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not invoice_created:
|
||||||
|
logger.info(f"Found existing pending invoice {invoice.invoice_number} for subscription")
|
||||||
|
|
||||||
# Mark invoice as paid
|
# Mark invoice as paid
|
||||||
InvoiceService.mark_paid(
|
InvoiceService.mark_paid(
|
||||||
invoice=invoice,
|
invoice=invoice,
|
||||||
@@ -481,10 +518,15 @@ def _activate_subscription(account, stripe_subscription_id: str, plan_id: str, s
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update account status if needed
|
# Update account status and plan
|
||||||
|
update_fields = ['updated_at']
|
||||||
if account.status != 'active':
|
if account.status != 'active':
|
||||||
account.status = 'active'
|
account.status = 'active'
|
||||||
account.save(update_fields=['status', 'updated_at'])
|
update_fields.append('status')
|
||||||
|
if account.plan_id != plan.id:
|
||||||
|
account.plan = plan
|
||||||
|
update_fields.append('plan')
|
||||||
|
account.save(update_fields=update_fields)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Subscription activated for account {account.id}: "
|
f"Subscription activated for account {account.id}: "
|
||||||
@@ -659,14 +701,32 @@ def _handle_subscription_updated(subscription_data: dict):
|
|||||||
stripe_status = subscription_data.get('status')
|
stripe_status = subscription_data.get('status')
|
||||||
new_status = status_map.get(stripe_status, 'active')
|
new_status = status_map.get(stripe_status, 'active')
|
||||||
|
|
||||||
# Update period dates
|
# Extract period dates - check subscription items first (new API), then subscription level
|
||||||
|
items = subscription_data.get('items', {}).get('data', [])
|
||||||
|
if items:
|
||||||
|
first_item = items[0]
|
||||||
|
period_start = first_item.get('current_period_start')
|
||||||
|
period_end = first_item.get('current_period_end')
|
||||||
|
else:
|
||||||
|
period_start = subscription_data.get('current_period_start')
|
||||||
|
period_end = subscription_data.get('current_period_end')
|
||||||
|
|
||||||
|
# Fallback to billing_cycle_anchor if period dates not found
|
||||||
|
if not period_start:
|
||||||
|
period_start = subscription_data.get('billing_cycle_anchor') or subscription_data.get('start_date')
|
||||||
|
if not period_end and period_start:
|
||||||
|
period_end = period_start + (30 * 24 * 60 * 60) # Default 30 days
|
||||||
|
|
||||||
|
# Only update period dates if we have valid values
|
||||||
|
if period_start:
|
||||||
subscription.current_period_start = datetime.fromtimestamp(
|
subscription.current_period_start = datetime.fromtimestamp(
|
||||||
subscription_data.get('current_period_start'),
|
period_start,
|
||||||
tz=timezone.utc
|
tz=dt_timezone.utc
|
||||||
)
|
)
|
||||||
|
if period_end:
|
||||||
subscription.current_period_end = datetime.fromtimestamp(
|
subscription.current_period_end = datetime.fromtimestamp(
|
||||||
subscription_data.get('current_period_end'),
|
period_end,
|
||||||
tz=timezone.utc
|
tz=dt_timezone.utc
|
||||||
)
|
)
|
||||||
subscription.cancel_at_period_end = subscription_data.get('cancel_at_period_end', False)
|
subscription.cancel_at_period_end = subscription_data.get('cancel_at_period_end', False)
|
||||||
subscription.status = new_status
|
subscription.status = new_status
|
||||||
|
|||||||
@@ -520,6 +520,30 @@ class PaymentMethodConfigAdmin(Igny8ModelAdmin):
|
|||||||
list_editable = ['is_enabled', 'sort_order']
|
list_editable = ['is_enabled', 'sort_order']
|
||||||
readonly_fields = ['created_at', 'updated_at']
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Payment Method', {
|
||||||
|
'fields': ('country_code', 'payment_method', 'display_name', 'is_enabled', 'sort_order')
|
||||||
|
}),
|
||||||
|
('Instructions', {
|
||||||
|
'fields': ('instructions',),
|
||||||
|
'description': 'Instructions shown to users for this payment method'
|
||||||
|
}),
|
||||||
|
('Bank Transfer Details', {
|
||||||
|
'fields': ('bank_name', 'account_title', 'account_number', 'routing_number', 'swift_code', 'iban'),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
'description': 'Only for bank_transfer payment method'
|
||||||
|
}),
|
||||||
|
('Local Wallet Details', {
|
||||||
|
'fields': ('wallet_type', 'wallet_id'),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
'description': 'Only for local_wallet payment method (JazzCash, EasyPaisa, etc.)'
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': ('created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(AccountPaymentMethod)
|
@admin.register(AccountPaymentMethod)
|
||||||
class AccountPaymentMethodAdmin(AccountAdminMixin, Igny8ModelAdmin):
|
class AccountPaymentMethodAdmin(AccountAdminMixin, Igny8ModelAdmin):
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# Generated by Django 5.2.9 on 2026-01-07 03:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billing', '0027_model_schema_update'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='paymentmethodconfig',
|
||||||
|
name='api_key',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='paymentmethodconfig',
|
||||||
|
name='api_secret',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='paymentmethodconfig',
|
||||||
|
name='webhook_secret',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='paymentmethodconfig',
|
||||||
|
name='webhook_url',
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='paymentmethodconfig',
|
||||||
|
name='account_title',
|
||||||
|
field=models.CharField(blank=True, help_text='Account holder name', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='paymentmethodconfig',
|
||||||
|
name='iban',
|
||||||
|
field=models.CharField(blank=True, help_text='IBAN for international transfers', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='paymentmethodconfig',
|
||||||
|
name='country_code',
|
||||||
|
field=models.CharField(db_index=True, help_text="ISO 2-letter country code (e.g., US, GB, PK) or '*' for global", max_length=2),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='paymentmethodconfig',
|
||||||
|
name='routing_number',
|
||||||
|
field=models.CharField(blank=True, help_text='Routing/Sort code', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='paymentmethodconfig',
|
||||||
|
name='swift_code',
|
||||||
|
field=models.CharField(blank=True, help_text='SWIFT/BIC code for international', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='paymentmethodconfig',
|
||||||
|
name='wallet_id',
|
||||||
|
field=models.CharField(blank=True, help_text='Mobile number or wallet ID', max_length=255),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='paymentmethodconfig',
|
||||||
|
name='wallet_type',
|
||||||
|
field=models.CharField(blank=True, help_text='E.g., JazzCash, EasyPaisa, etc.', max_length=100),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -846,3 +846,6 @@ STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET', '')
|
|||||||
PAYPAL_CLIENT_ID = os.getenv('PAYPAL_CLIENT_ID', '')
|
PAYPAL_CLIENT_ID = os.getenv('PAYPAL_CLIENT_ID', '')
|
||||||
PAYPAL_CLIENT_SECRET = os.getenv('PAYPAL_CLIENT_SECRET', '')
|
PAYPAL_CLIENT_SECRET = os.getenv('PAYPAL_CLIENT_SECRET', '')
|
||||||
PAYPAL_API_BASE = os.getenv('PAYPAL_API_BASE', 'https://api-m.sandbox.paypal.com')
|
PAYPAL_API_BASE = os.getenv('PAYPAL_API_BASE', 'https://api-m.sandbox.paypal.com')
|
||||||
|
|
||||||
|
# Frontend URL for redirects (Stripe/PayPal success/cancel URLs)
|
||||||
|
FRONTEND_URL = os.getenv('FRONTEND_URL', 'https://app.igny8.com')
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from igny8_core.auth.views import (
|
|||||||
industrysector_csv_template, industrysector_csv_import,
|
industrysector_csv_template, industrysector_csv_import,
|
||||||
seedkeyword_csv_template, seedkeyword_csv_import
|
seedkeyword_csv_template, seedkeyword_csv_import
|
||||||
)
|
)
|
||||||
|
from igny8_core.utils.geo_views import GeoDetectView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# CSV Import/Export for admin - MUST come before admin/ to avoid being caught by admin.site.urls
|
# CSV Import/Export for admin - MUST come before admin/ to avoid being caught by admin.site.urls
|
||||||
@@ -49,6 +50,7 @@ urlpatterns = [
|
|||||||
path('api/v1/optimizer/', include('igny8_core.modules.optimizer.urls')), # Optimizer endpoints
|
path('api/v1/optimizer/', include('igny8_core.modules.optimizer.urls')), # Optimizer endpoints
|
||||||
path('api/v1/publisher/', include('igny8_core.modules.publisher.urls')), # Publisher endpoints
|
path('api/v1/publisher/', include('igny8_core.modules.publisher.urls')), # Publisher endpoints
|
||||||
path('api/v1/integration/', include('igny8_core.modules.integration.urls')), # Integration endpoints
|
path('api/v1/integration/', include('igny8_core.modules.integration.urls')), # Integration endpoints
|
||||||
|
path('api/v1/geo/detect/', GeoDetectView.as_view(), name='geo-detect'), # Geo detection for signup routing
|
||||||
# OpenAPI Schema and Documentation
|
# OpenAPI Schema and Documentation
|
||||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||||
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||||
|
|||||||
42
backend/igny8_core/utils/geo_views.py
Normal file
42
backend/igny8_core/utils/geo_views.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Utility views for miscellaneous API endpoints
|
||||||
|
"""
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework import permissions
|
||||||
|
|
||||||
|
|
||||||
|
class GeoDetectView(APIView):
|
||||||
|
"""
|
||||||
|
Detect user's country from request headers.
|
||||||
|
|
||||||
|
Uses Cloudflare's CF-IPCountry header if behind Cloudflare,
|
||||||
|
otherwise falls back to checking X-Country-Code or other headers.
|
||||||
|
|
||||||
|
This endpoint is used by the frontend to determine which signup page
|
||||||
|
to show (/signup for global, /signup/pk for Pakistan).
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request):
|
||||||
|
# Try Cloudflare header first (most reliable when behind CF)
|
||||||
|
country_code = request.META.get('HTTP_CF_IPCOUNTRY', '')
|
||||||
|
|
||||||
|
# Fallback to X-Country-Code header (can be set by load balancer/CDN)
|
||||||
|
if not country_code:
|
||||||
|
country_code = request.META.get('HTTP_X_COUNTRY_CODE', '')
|
||||||
|
|
||||||
|
# Fallback to GeoIP2 if available (Django GeoIP2 middleware)
|
||||||
|
if not country_code:
|
||||||
|
country_code = getattr(request, 'country_code', '')
|
||||||
|
|
||||||
|
# Default to empty string (frontend will treat as global)
|
||||||
|
country_code = country_code.upper() if country_code else ''
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'success': True,
|
||||||
|
'data': {
|
||||||
|
'country_code': country_code,
|
||||||
|
'detected': bool(country_code),
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -15,6 +15,7 @@ import SuspenseLoader from "./components/common/SuspenseLoader";
|
|||||||
// Auth pages - loaded immediately (needed for login)
|
// Auth pages - loaded immediately (needed for login)
|
||||||
import SignIn from "./pages/AuthPages/SignIn";
|
import SignIn from "./pages/AuthPages/SignIn";
|
||||||
import SignUp from "./pages/AuthPages/SignUp";
|
import SignUp from "./pages/AuthPages/SignUp";
|
||||||
|
import SignUpPK from "./pages/AuthPages/SignUpPK";
|
||||||
import Payment from "./pages/Payment";
|
import Payment from "./pages/Payment";
|
||||||
import NotFound from "./pages/OtherPage/NotFound";
|
import NotFound from "./pages/OtherPage/NotFound";
|
||||||
|
|
||||||
@@ -138,6 +139,7 @@ export default function App() {
|
|||||||
{/* Auth Routes - Public */}
|
{/* Auth Routes - Public */}
|
||||||
<Route path="/signin" element={<SignIn />} />
|
<Route path="/signin" element={<SignIn />} />
|
||||||
<Route path="/signup" element={<SignUp />} />
|
<Route path="/signup" element={<SignUp />} />
|
||||||
|
<Route path="/signup/pk" element={<SignUpPK />} />
|
||||||
<Route path="/payment" element={<Payment />} />
|
<Route path="/payment" element={<Payment />} />
|
||||||
|
|
||||||
{/* Legal Pages - Public */}
|
{/* Legal Pages - Public */}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Unified Signup Form with Integrated Pricing Selection
|
* Unified Signup Form with Integrated Pricing Selection
|
||||||
* Combines free and paid signup flows in one modern interface
|
* Combines free and paid signup flows in one modern interface
|
||||||
|
*
|
||||||
|
* Payment Methods:
|
||||||
|
* - Most countries: Credit/Debit Card (Stripe) + PayPal
|
||||||
|
* - Pakistan (PK): Credit/Debit Card (Stripe) + Bank Transfer
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
@@ -14,6 +18,13 @@ import Button from '../ui/button/Button';
|
|||||||
import SelectDropdown from '../form/SelectDropdown';
|
import SelectDropdown from '../form/SelectDropdown';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
|
||||||
|
// PayPal icon component
|
||||||
|
const PayPalIcon = ({ className }: { className?: string }) => (
|
||||||
|
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M7.076 21.337H2.47a.641.641 0 0 1-.633-.74L4.944.901C5.026.382 5.474 0 5.998 0h7.46c2.57 0 4.578.543 5.69 1.81 1.01 1.15 1.304 2.42 1.012 4.287-.023.143-.047.288-.077.437-.983 5.05-4.349 6.797-8.647 6.797h-2.19c-.524 0-.968.382-1.05.9l-1.12 7.106zm14.146-14.42a3.35 3.35 0 0 0-.607-.541c-.013.076-.026.175-.041.254-.93 4.778-4.005 7.201-9.138 7.201h-2.19a.563.563 0 0 0-.556.479l-1.187 7.527h-.506l-.24 1.516a.56.56 0 0 0 .554.647h3.882c.46 0 .85-.334.922-.788.06-.26.76-4.852.816-5.09a.932.932 0 0 1 .923-.788h.58c3.76 0 6.705-1.528 7.565-5.946.36-1.847.174-3.388-.777-4.471z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
interface Plan {
|
interface Plan {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -38,11 +49,21 @@ interface PaymentMethodConfig {
|
|||||||
is_enabled: boolean;
|
is_enabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Payment method option type
|
||||||
|
interface PaymentOption {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
interface SignUpFormUnifiedProps {
|
interface SignUpFormUnifiedProps {
|
||||||
plans: Plan[];
|
plans: Plan[];
|
||||||
selectedPlan: Plan | null;
|
selectedPlan: Plan | null;
|
||||||
onPlanSelect: (plan: Plan) => void;
|
onPlanSelect: (plan: Plan) => void;
|
||||||
plansLoading: boolean;
|
plansLoading: boolean;
|
||||||
|
countryCode?: string; // Optional: 'PK' for Pakistan-specific, empty for global
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SignUpFormUnified({
|
export default function SignUpFormUnified({
|
||||||
@@ -50,6 +71,7 @@ export default function SignUpFormUnified({
|
|||||||
selectedPlan,
|
selectedPlan,
|
||||||
onPlanSelect,
|
onPlanSelect,
|
||||||
plansLoading,
|
plansLoading,
|
||||||
|
countryCode = '', // Default to global (empty = show Credit Card + PayPal)
|
||||||
}: SignUpFormUnifiedProps) {
|
}: SignUpFormUnifiedProps) {
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [isChecked, setIsChecked] = useState(false);
|
const [isChecked, setIsChecked] = useState(false);
|
||||||
@@ -61,11 +83,12 @@ export default function SignUpFormUnified({
|
|||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
accountName: '',
|
accountName: '',
|
||||||
billingCountry: 'US',
|
billingCountry: countryCode || 'US',
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>('');
|
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<string>('stripe');
|
||||||
const [paymentMethods, setPaymentMethods] = useState<PaymentMethodConfig[]>([]);
|
const [availablePaymentOptions, setAvailablePaymentOptions] = useState<PaymentOption[]>([]);
|
||||||
|
const [backendPaymentMethods, setBackendPaymentMethods] = useState<PaymentMethodConfig[]>([]);
|
||||||
const [paymentMethodsLoading, setPaymentMethodsLoading] = useState(false);
|
const [paymentMethodsLoading, setPaymentMethodsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
@@ -74,6 +97,9 @@ export default function SignUpFormUnified({
|
|||||||
|
|
||||||
const isPaidPlan = selectedPlan && parseFloat(String(selectedPlan.price || 0)) > 0;
|
const isPaidPlan = selectedPlan && parseFloat(String(selectedPlan.price || 0)) > 0;
|
||||||
|
|
||||||
|
// Determine if this is a Pakistan-specific signup
|
||||||
|
const isPakistanSignup = countryCode === 'PK';
|
||||||
|
|
||||||
// Update URL when plan changes
|
// Update URL when plan changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPlan) {
|
if (selectedPlan) {
|
||||||
@@ -83,10 +109,10 @@ export default function SignUpFormUnified({
|
|||||||
}
|
}
|
||||||
}, [selectedPlan]);
|
}, [selectedPlan]);
|
||||||
|
|
||||||
// Load payment methods for paid plans
|
// Load payment methods from backend and determine available options
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPaidPlan) {
|
if (!isPaidPlan) {
|
||||||
setPaymentMethods([]);
|
setAvailablePaymentOptions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,8 +120,7 @@ export default function SignUpFormUnified({
|
|||||||
setPaymentMethodsLoading(true);
|
setPaymentMethodsLoading(true);
|
||||||
try {
|
try {
|
||||||
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
|
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
|
||||||
const country = formData.billingCountry || 'US';
|
const response = await fetch(`${API_BASE_URL}/v1/billing/payment-configs/payment-methods/`);
|
||||||
const response = await fetch(`${API_BASE_URL}/v1/billing/payment-configs/payment-methods/?country=${country}`);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to load payment methods');
|
throw new Error('Failed to load payment methods');
|
||||||
@@ -113,22 +138,79 @@ export default function SignUpFormUnified({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const enabledMethods = methodsList.filter((m: PaymentMethodConfig) => m.is_enabled);
|
const enabledMethods = methodsList.filter((m: PaymentMethodConfig) => m.is_enabled);
|
||||||
setPaymentMethods(enabledMethods);
|
setBackendPaymentMethods(enabledMethods);
|
||||||
|
|
||||||
if (enabledMethods.length > 0 && !selectedPaymentMethod) {
|
// Build payment options based on signup type (PK vs Global)
|
||||||
setSelectedPaymentMethod(enabledMethods[0].payment_method);
|
const options: PaymentOption[] = [];
|
||||||
|
|
||||||
|
// Always show Credit/Debit Card (Stripe) if enabled
|
||||||
|
const stripeEnabled = enabledMethods.some(m => m.payment_method === 'stripe');
|
||||||
|
if (stripeEnabled) {
|
||||||
|
options.push({
|
||||||
|
id: 'stripe',
|
||||||
|
type: 'stripe',
|
||||||
|
name: 'Credit/Debit Card',
|
||||||
|
description: 'Pay securely with Visa, Mastercard, or other cards',
|
||||||
|
icon: <CreditCardIcon className="w-6 h-6" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For Pakistan signup (/signup/pk): show Bank Transfer
|
||||||
|
// For Global signup (/signup): show PayPal
|
||||||
|
if (isPakistanSignup) {
|
||||||
|
// Pakistan: show Bank Transfer as 2nd option
|
||||||
|
const bankTransferEnabled = enabledMethods.some(
|
||||||
|
m => m.payment_method === 'bank_transfer' && (!m.country_code || m.country_code === 'PK')
|
||||||
|
);
|
||||||
|
if (bankTransferEnabled) {
|
||||||
|
options.push({
|
||||||
|
id: 'bank_transfer',
|
||||||
|
type: 'bank_transfer',
|
||||||
|
name: 'Bank Transfer',
|
||||||
|
description: 'Pay via bank transfer (PKR)',
|
||||||
|
icon: <Building2Icon className="w-6 h-6" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Global: show PayPal as 2nd option
|
||||||
|
const paypalEnabled = enabledMethods.some(m => m.payment_method === 'paypal');
|
||||||
|
if (paypalEnabled) {
|
||||||
|
options.push({
|
||||||
|
id: 'paypal',
|
||||||
|
type: 'paypal',
|
||||||
|
name: 'PayPal',
|
||||||
|
description: 'Pay with your PayPal account',
|
||||||
|
icon: <PayPalIcon className="w-6 h-6" />,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvailablePaymentOptions(options);
|
||||||
|
|
||||||
|
// Set default payment method
|
||||||
|
if (options.length > 0 && !options.find(o => o.type === selectedPaymentMethod)) {
|
||||||
|
setSelectedPaymentMethod(options[0].type);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to load payment methods:', err);
|
console.error('Failed to load payment methods:', err);
|
||||||
// Don't set error for free plans or if payment methods fail to load
|
// Fallback to default options
|
||||||
// Just log it and continue
|
setAvailablePaymentOptions([
|
||||||
|
{
|
||||||
|
id: 'stripe',
|
||||||
|
type: 'stripe',
|
||||||
|
name: 'Credit/Debit Card',
|
||||||
|
description: 'Pay securely with Visa, Mastercard, or other cards',
|
||||||
|
icon: <CreditCardIcon className="w-6 h-6" />,
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
setSelectedPaymentMethod('stripe');
|
||||||
} finally {
|
} finally {
|
||||||
setPaymentMethodsLoading(false);
|
setPaymentMethodsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadPaymentMethods();
|
loadPaymentMethods();
|
||||||
}, [isPaidPlan, formData.billingCountry]);
|
}, [isPaidPlan, isPakistanSignup]);
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
@@ -180,27 +262,33 @@ export default function SignUpFormUnified({
|
|||||||
|
|
||||||
const user = (await register(registerPayload)) as any;
|
const user = (await register(registerPayload)) as any;
|
||||||
|
|
||||||
|
// Log full registration response for debugging
|
||||||
|
console.log('Registration response:', {
|
||||||
|
user: user,
|
||||||
|
checkoutUrl: user?.checkout_url,
|
||||||
|
selectedPaymentMethod: selectedPaymentMethod,
|
||||||
|
accountStatus: user?.account?.status
|
||||||
|
});
|
||||||
|
|
||||||
// CRITICAL: Verify auth state is actually set in Zustand store
|
// CRITICAL: Verify auth state is actually set in Zustand store
|
||||||
// The register function should have already set isAuthenticated=true
|
|
||||||
const currentAuthState = useAuthStore.getState();
|
const currentAuthState = useAuthStore.getState();
|
||||||
|
|
||||||
console.log('Post-registration auth state check:', {
|
console.log('Post-registration auth state check:', {
|
||||||
isAuthenticated: currentAuthState.isAuthenticated,
|
isAuthenticated: currentAuthState.isAuthenticated,
|
||||||
hasUser: !!currentAuthState.user,
|
hasUser: !!currentAuthState.user,
|
||||||
hasToken: !!currentAuthState.token,
|
hasToken: !!currentAuthState.token,
|
||||||
userData: user
|
userData: user,
|
||||||
|
checkoutUrl: user?.checkout_url
|
||||||
});
|
});
|
||||||
|
|
||||||
// If for some reason state wasn't set, force set it again
|
// If for some reason state wasn't set, force set it again
|
||||||
if (!currentAuthState.isAuthenticated || !currentAuthState.user || !currentAuthState.token) {
|
if (!currentAuthState.isAuthenticated || !currentAuthState.user || !currentAuthState.token) {
|
||||||
console.error('Auth state not properly set after registration, forcing update...');
|
console.error('Auth state not properly set after registration, forcing update...');
|
||||||
|
|
||||||
// Extract tokens from user data if available
|
|
||||||
const tokenData = user?.tokens || {};
|
const tokenData = user?.tokens || {};
|
||||||
const accessToken = user?.access || tokenData.access || localStorage.getItem('access_token');
|
const accessToken = user?.access || tokenData.access || localStorage.getItem('access_token');
|
||||||
const refreshToken = user?.refresh || tokenData.refresh || localStorage.getItem('refresh_token');
|
const refreshToken = user?.refresh || tokenData.refresh || localStorage.getItem('refresh_token');
|
||||||
|
|
||||||
// Force set the state
|
|
||||||
useAuthStore.setState({
|
useAuthStore.setState({
|
||||||
user: user,
|
user: user,
|
||||||
token: accessToken,
|
token: accessToken,
|
||||||
@@ -209,7 +297,6 @@ export default function SignUpFormUnified({
|
|||||||
loading: false
|
loading: false
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait a bit for state to propagate
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,30 +306,46 @@ export default function SignUpFormUnified({
|
|||||||
throw new Error('Failed to authenticate after registration. Please try logging in manually.');
|
throw new Error('Failed to authenticate after registration. Please try logging in manually.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = user?.account?.status;
|
// Handle payment gateway redirects
|
||||||
if (status === 'pending_payment') {
|
const checkoutUrl = user?.checkout_url;
|
||||||
navigate('/account/plans', { replace: true });
|
|
||||||
} else {
|
console.log('Payment redirect decision:', {
|
||||||
navigate('/sites', { replace: true });
|
checkoutUrl,
|
||||||
|
selectedPaymentMethod,
|
||||||
|
isPaidPlan,
|
||||||
|
fullUserResponse: user,
|
||||||
|
});
|
||||||
|
|
||||||
|
// For Stripe or PayPal with checkout URL - redirect to payment gateway
|
||||||
|
if (checkoutUrl && (selectedPaymentMethod === 'stripe' || selectedPaymentMethod === 'paypal')) {
|
||||||
|
console.log(`Redirecting to ${selectedPaymentMethod} checkout:`, checkoutUrl);
|
||||||
|
window.location.href = checkoutUrl;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For bank_transfer ONLY - go to plans page to show payment instructions
|
||||||
|
// This is the expected flow for bank transfer
|
||||||
|
if (selectedPaymentMethod === 'bank_transfer') {
|
||||||
|
console.log('Bank transfer selected, redirecting to plans page for payment confirmation');
|
||||||
|
navigate('/account/plans', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If Stripe/PayPal but no checkout URL (error case) - still go to plans page
|
||||||
|
// User can retry payment from there
|
||||||
|
if (isPaidPlan && !checkoutUrl) {
|
||||||
|
console.warn('Paid plan selected but no checkout URL received - going to plans page');
|
||||||
|
navigate('/account/plans', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For free plans - go to sites page
|
||||||
|
navigate('/sites', { replace: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Registration failed. Please try again.');
|
setError(err.message || 'Registration failed. Please try again.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPaymentIcon = (method: string) => {
|
|
||||||
switch (method) {
|
|
||||||
case 'stripe':
|
|
||||||
return <CreditCardIcon className="w-5 h-5" />;
|
|
||||||
case 'bank_transfer':
|
|
||||||
return <Building2Icon className="w-5 h-5" />;
|
|
||||||
case 'local_wallet':
|
|
||||||
return <WalletIcon className="w-5 h-5" />;
|
|
||||||
default:
|
|
||||||
return <CreditCardIcon className="w-5 h-5" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatNumber = (num: number): string => {
|
const formatNumber = (num: number): string => {
|
||||||
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`;
|
||||||
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`;
|
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`;
|
||||||
@@ -426,80 +529,74 @@ export default function SignUpFormUnified({
|
|||||||
|
|
||||||
{isPaidPlan && (
|
{isPaidPlan && (
|
||||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700 space-y-4">
|
<div className="pt-4 border-t border-gray-200 dark:border-gray-700 space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
{/* Payment Method Selection - Card Style */}
|
||||||
<div>
|
<div>
|
||||||
<Label>
|
<Label className="mb-3">
|
||||||
Country<span className="text-error-500">*</span>
|
Select Payment Method<span className="text-error-500">*</span>
|
||||||
</Label>
|
|
||||||
<SelectDropdown
|
|
||||||
options={[
|
|
||||||
{ value: 'US', label: '🇺🇸 United States' },
|
|
||||||
{ value: 'GB', label: '🇬🇧 United Kingdom' },
|
|
||||||
{ value: 'IN', label: '🇮🇳 India' },
|
|
||||||
{ value: 'PK', label: '🇵🇰 Pakistan' },
|
|
||||||
{ value: 'CA', label: '🇨🇦 Canada' },
|
|
||||||
{ value: 'AU', label: '🇦🇺 Australia' },
|
|
||||||
{ value: 'DE', label: '🇩🇪 Germany' },
|
|
||||||
{ value: 'FR', label: '🇫🇷 France' },
|
|
||||||
]}
|
|
||||||
value={formData.billingCountry}
|
|
||||||
onChange={(value) => setFormData((prev) => ({ ...prev, billingCountry: value }))}
|
|
||||||
className="text-base"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">Payment methods filtered by country</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label>
|
|
||||||
Payment Method<span className="text-error-500">*</span>
|
|
||||||
</Label>
|
</Label>
|
||||||
{paymentMethodsLoading ? (
|
{paymentMethodsLoading ? (
|
||||||
<div className="flex items-center justify-center p-4 bg-gray-50 dark:bg-gray-800 rounded-lg h-[52px]">
|
<div className="flex items-center justify-center p-6 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
<Loader2Icon className="w-4 h-4 animate-spin text-brand-500" />
|
<Loader2Icon className="w-5 h-5 animate-spin text-brand-500 mr-2" />
|
||||||
|
<span className="text-sm text-gray-500">Loading payment options...</span>
|
||||||
</div>
|
</div>
|
||||||
) : paymentMethods.length === 0 ? (
|
) : availablePaymentOptions.length === 0 ? (
|
||||||
<div className="p-3 bg-warning-50 border border-warning-200 rounded-lg text-warning-800 dark:bg-warning-900/20 dark:border-warning-800 dark:text-warning-200">
|
<div className="p-4 bg-warning-50 border border-warning-200 rounded-lg text-warning-800 dark:bg-warning-900/20 dark:border-warning-800 dark:text-warning-200">
|
||||||
<p className="text-xs">No payment methods available</p>
|
<p className="text-sm">No payment methods available for your region</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SelectDropdown
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
options={paymentMethods.map(m => ({
|
{availablePaymentOptions.map((option) => (
|
||||||
value: m.payment_method,
|
<button
|
||||||
label: m.display_name
|
key={option.id}
|
||||||
}))}
|
type="button"
|
||||||
value={selectedPaymentMethod}
|
onClick={() => setSelectedPaymentMethod(option.type)}
|
||||||
onChange={(value) => setSelectedPaymentMethod(value)}
|
className={`relative p-4 rounded-xl border-2 text-left transition-all ${
|
||||||
className="text-base"
|
selectedPaymentMethod === option.type
|
||||||
/>
|
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20'
|
||||||
)}
|
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 bg-white dark:bg-gray-800'
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">How you'd like to pay</p>
|
}`}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Payment Method Details - Full Width Below */}
|
|
||||||
{selectedPaymentMethod && paymentMethods.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{paymentMethods.filter(m => m.payment_method === selectedPaymentMethod).map((method) => (
|
|
||||||
method.instructions && (
|
|
||||||
<div
|
|
||||||
key={method.id}
|
|
||||||
className="p-4 rounded-lg border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50"
|
|
||||||
>
|
>
|
||||||
|
{selectedPaymentMethod === option.type && (
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<div className="w-5 h-5 bg-brand-500 rounded-full flex items-center justify-center">
|
||||||
|
<CheckIcon className="w-3 h-3 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-brand-500 text-white flex-shrink-0">
|
<div className={`flex items-center justify-center w-10 h-10 rounded-lg ${
|
||||||
{getPaymentIcon(method.payment_method)}
|
selectedPaymentMethod === option.type
|
||||||
|
? 'bg-brand-500 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||||
|
}`}>
|
||||||
|
{option.icon}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<h4 className="font-semibold text-gray-900 dark:text-white text-sm mb-1">{method.display_name}</h4>
|
<h4 className={`font-semibold text-sm ${
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400 whitespace-pre-line">{method.instructions}</p>
|
selectedPaymentMethod === option.type
|
||||||
|
? 'text-brand-700 dark:text-brand-400'
|
||||||
|
: 'text-gray-900 dark:text-white'
|
||||||
|
}`}>
|
||||||
|
{option.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
|
{option.description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
)
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Pakistan signup notice */}
|
||||||
|
{isPakistanSignup && (
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||||
|
<span>🇵🇰</span> Pakistan - Bank transfer available
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-start gap-3 pt-2">
|
<div className="flex items-start gap-3 pt-2">
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ export default function PendingPaymentBanner({ className = '' }: PendingPaymentB
|
|||||||
const accountStatus = user?.account?.status;
|
const accountStatus = user?.account?.status;
|
||||||
const isPendingPayment = accountStatus === 'pending_payment';
|
const isPendingPayment = accountStatus === 'pending_payment';
|
||||||
|
|
||||||
|
// Clear dismissed state when account is no longer pending payment
|
||||||
|
// This ensures the banner shows again if account reverts to pending
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isPendingPayment) {
|
||||||
|
sessionStorage.removeItem('payment-banner-dismissed');
|
||||||
|
}
|
||||||
|
}, [isPendingPayment]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isPendingPayment && !dismissed) {
|
if (isPendingPayment && !dismissed) {
|
||||||
loadPendingInvoice();
|
loadPendingInvoice();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link, useNavigate } from "react-router-dom";
|
||||||
import PageMeta from "../../components/common/PageMeta";
|
import PageMeta from "../../components/common/PageMeta";
|
||||||
import SignUpFormUnified from "../../components/auth/SignUpFormUnified";
|
import SignUpFormUnified from "../../components/auth/SignUpFormUnified";
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ interface Plan {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SignUp() {
|
export default function SignUp() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const planSlug = useMemo(() => {
|
const planSlug = useMemo(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
return params.get("plan") || "";
|
return params.get("plan") || "";
|
||||||
@@ -27,6 +28,38 @@ export default function SignUp() {
|
|||||||
const [plans, setPlans] = useState<Plan[]>([]);
|
const [plans, setPlans] = useState<Plan[]>([]);
|
||||||
const [plansLoading, setPlansLoading] = useState(true);
|
const [plansLoading, setPlansLoading] = useState(true);
|
||||||
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
||||||
|
const [geoChecked, setGeoChecked] = useState(false);
|
||||||
|
|
||||||
|
// Check geo location and redirect PK users to /signup/pk
|
||||||
|
// Using free public API: https://api.country.is (no signup required, CORS enabled)
|
||||||
|
useEffect(() => {
|
||||||
|
const checkGeoAndRedirect = async () => {
|
||||||
|
try {
|
||||||
|
// Free public geo API - no signup required
|
||||||
|
const response = await fetch('https://api.country.is/', {
|
||||||
|
signal: AbortSignal.timeout(3000), // 3 second timeout
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const countryCode = data?.country;
|
||||||
|
|
||||||
|
if (countryCode === 'PK') {
|
||||||
|
// Preserve query params when redirecting
|
||||||
|
const queryString = window.location.search;
|
||||||
|
navigate(`/signup/pk${queryString}`, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Silently fail - continue with global signup
|
||||||
|
console.log('Geo detection failed, using global signup');
|
||||||
|
}
|
||||||
|
setGeoChecked(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkGeoAndRedirect();
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPlans = async () => {
|
const fetchPlans = async () => {
|
||||||
@@ -68,6 +101,15 @@ export default function SignUp() {
|
|||||||
fetchPlans();
|
fetchPlans();
|
||||||
}, [planSlug]);
|
}, [planSlug]);
|
||||||
|
|
||||||
|
// Don't render until geo check is complete (prevents flash)
|
||||||
|
if (!geoChecked) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-500"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageMeta
|
<PageMeta
|
||||||
|
|||||||
116
frontend/src/pages/AuthPages/SignUpPK.tsx
Normal file
116
frontend/src/pages/AuthPages/SignUpPK.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* SignUpPK - Pakistan-specific signup page
|
||||||
|
* Shows Credit/Debit Card + Bank Transfer payment options
|
||||||
|
*
|
||||||
|
* Route: /signup/pk
|
||||||
|
*/
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import PageMeta from "../../components/common/PageMeta";
|
||||||
|
import SignUpFormUnified from "../../components/auth/SignUpFormUnified";
|
||||||
|
|
||||||
|
interface Plan {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
price: string | number;
|
||||||
|
billing_cycle: string;
|
||||||
|
is_active: boolean;
|
||||||
|
max_users: number;
|
||||||
|
max_sites: number;
|
||||||
|
max_keywords: number;
|
||||||
|
max_ahrefs_queries: number;
|
||||||
|
included_credits: number;
|
||||||
|
features: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SignUpPK() {
|
||||||
|
const planSlug = useMemo(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return params.get("plan") || "";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [plans, setPlans] = useState<Plan[]>([]);
|
||||||
|
const [plansLoading, setPlansLoading] = useState(true);
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPlans = async () => {
|
||||||
|
setPlansLoading(true);
|
||||||
|
try {
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || "https://api.igny8.com/api";
|
||||||
|
const res = await fetch(`${API_BASE_URL}/v1/auth/plans/`);
|
||||||
|
const data = await res.json();
|
||||||
|
const allPlans = data?.results || [];
|
||||||
|
|
||||||
|
// Show all active plans (including free plan)
|
||||||
|
const publicPlans = allPlans
|
||||||
|
.filter((p: Plan) => p.is_active)
|
||||||
|
.sort((a: Plan, b: Plan) => {
|
||||||
|
const priceA = typeof a.price === 'number' ? a.price : parseFloat(String(a.price || 0));
|
||||||
|
const priceB = typeof b.price === 'number' ? b.price : parseFloat(String(b.price || 0));
|
||||||
|
return priceA - priceB;
|
||||||
|
});
|
||||||
|
|
||||||
|
setPlans(publicPlans);
|
||||||
|
|
||||||
|
// Auto-select plan from URL or default to first plan
|
||||||
|
if (planSlug) {
|
||||||
|
const plan = publicPlans.find((p: Plan) => p.slug === planSlug);
|
||||||
|
if (plan) {
|
||||||
|
setSelectedPlan(plan);
|
||||||
|
} else {
|
||||||
|
setSelectedPlan(publicPlans[0] || null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedPlan(publicPlans[0] || null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load plans:', e);
|
||||||
|
} finally {
|
||||||
|
setPlansLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchPlans();
|
||||||
|
}, [planSlug]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageMeta
|
||||||
|
title="Sign Up (Pakistan) - IGNY8"
|
||||||
|
description="Create your IGNY8 account and start building topical authority with AI-powered content"
|
||||||
|
/>
|
||||||
|
<div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800">
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* Left Side - Signup Form with Pakistan-specific payment options */}
|
||||||
|
<SignUpFormUnified
|
||||||
|
plans={plans}
|
||||||
|
selectedPlan={selectedPlan}
|
||||||
|
onPlanSelect={setSelectedPlan}
|
||||||
|
plansLoading={plansLoading}
|
||||||
|
countryCode="PK"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right Side - Pricing Plans */}
|
||||||
|
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-brand-50 to-purple-50 dark:from-gray-900 dark:to-gray-800 p-8 xl:p-12 items-start justify-center relative">
|
||||||
|
{/* Logo - Top Right */}
|
||||||
|
<Link to="/" className="absolute top-6 right-6">
|
||||||
|
<img
|
||||||
|
src="/images/logo/IGNY8_LIGHT_LOGO.png"
|
||||||
|
alt="IGNY8"
|
||||||
|
className="h-12 w-auto"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="w-full max-w-2xl mt-20">
|
||||||
|
{/* Pricing Plans Component Will Load Here */}
|
||||||
|
<div id="signup-pricing-plans" className="w-full">
|
||||||
|
{/* Plans will be rendered by SignUpFormUnified */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import PageMeta from '../../components/common/PageMeta';
|
import PageMeta from '../../components/common/PageMeta';
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
import { fetchAPI } from '../../services/api';
|
import { fetchAPI } from '../../services/api';
|
||||||
import { PricingPlan } from '../../components/ui/pricing-table';
|
import { PricingPlan } from '../../components/ui/pricing-table';
|
||||||
import PricingTable1 from '../../components/ui/pricing-table/pricing-table-1';
|
import PricingTable1 from '../../components/ui/pricing-table/pricing-table-1';
|
||||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||||
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
|
||||||
interface Plan {
|
interface Plan {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -105,6 +107,45 @@ export default function Plans() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const { startLoading, stopLoading } = usePageLoading();
|
const { startLoading, stopLoading } = usePageLoading();
|
||||||
const [plans, setPlans] = useState<Plan[]>([]);
|
const [plans, setPlans] = useState<Plan[]>([]);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const { refreshUser } = useAuthStore();
|
||||||
|
|
||||||
|
// Handle payment success redirect from Stripe
|
||||||
|
useEffect(() => {
|
||||||
|
const success = searchParams.get('success');
|
||||||
|
const sessionId = searchParams.get('session_id');
|
||||||
|
|
||||||
|
if (success === 'true') {
|
||||||
|
// Clear the query params to avoid re-triggering
|
||||||
|
setSearchParams({});
|
||||||
|
|
||||||
|
// Refresh user data to get updated account status
|
||||||
|
refreshUser().then(() => {
|
||||||
|
toast.success('Payment successful! Your subscription is now active.');
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('Failed to refresh user after payment:', err);
|
||||||
|
// Still show success message since payment was processed
|
||||||
|
toast.success('Payment successful! Please refresh the page to see updates.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [searchParams, setSearchParams, refreshUser, toast]);
|
||||||
|
|
||||||
|
// Handle PayPal success redirect
|
||||||
|
useEffect(() => {
|
||||||
|
const paypal = searchParams.get('paypal');
|
||||||
|
|
||||||
|
if (paypal === 'success') {
|
||||||
|
setSearchParams({});
|
||||||
|
refreshUser().then(() => {
|
||||||
|
toast.success('PayPal payment successful! Your subscription is now active.');
|
||||||
|
}).catch(() => {
|
||||||
|
toast.success('PayPal payment successful! Please refresh the page to see updates.');
|
||||||
|
});
|
||||||
|
} else if (paypal === 'cancel') {
|
||||||
|
setSearchParams({});
|
||||||
|
toast.info('Payment was cancelled.');
|
||||||
|
}
|
||||||
|
}, [searchParams, setSearchParams, refreshUser, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPlans();
|
loadPlans();
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import PageMeta from '../../components/common/PageMeta';
|
|||||||
import PageHeader from '../../components/common/PageHeader';
|
import PageHeader from '../../components/common/PageHeader';
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
import { usePageLoading } from '../../context/PageLoadingContext';
|
import { usePageLoading } from '../../context/PageLoadingContext';
|
||||||
|
import { formatCurrency } from '../../utils';
|
||||||
import {
|
import {
|
||||||
getCreditBalance,
|
getCreditBalance,
|
||||||
getCreditPackages,
|
getCreditPackages,
|
||||||
@@ -711,7 +712,7 @@ export default function PlansAndBillingPage() {
|
|||||||
<td className="px-6 py-3 text-gray-600 dark:text-gray-400">
|
<td className="px-6 py-3 text-gray-600 dark:text-gray-400">
|
||||||
{new Date(invoice.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
{new Date(invoice.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-3 text-center font-semibold text-gray-900 dark:text-white">${invoice.total_amount}</td>
|
<td className="px-6 py-3 text-center font-semibold text-gray-900 dark:text-white">{formatCurrency(invoice.total_amount, invoice.currency)}</td>
|
||||||
<td className="px-6 py-3 text-center">
|
<td className="px-6 py-3 text-center">
|
||||||
<Badge variant="soft" tone={invoice.status === 'paid' ? 'success' : 'warning'}>
|
<Badge variant="soft" tone={invoice.status === 'paid' ? 'success' : 'warning'}>
|
||||||
{invoice.status}
|
{invoice.status}
|
||||||
|
|||||||
@@ -357,8 +357,20 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
throw new Error('Failed to save login session. Please try again.');
|
throw new Error('Failed to save login session. Please try again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return user data for success handling
|
// Return full response data for success handling (includes checkout_url for payment redirects)
|
||||||
return userData;
|
console.log('Extracting checkout_url:', {
|
||||||
|
'responseData.checkout_url': responseData.checkout_url,
|
||||||
|
'data.checkout_url': data.checkout_url,
|
||||||
|
'data.data?.checkout_url': data.data?.checkout_url,
|
||||||
|
responseData: responseData,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...userData,
|
||||||
|
checkout_url: responseData.checkout_url || data.checkout_url || data.data?.checkout_url,
|
||||||
|
checkout_session_id: responseData.checkout_session_id || data.checkout_session_id || data.data?.checkout_session_id,
|
||||||
|
paypal_order_id: responseData.paypal_order_id || data.paypal_order_id || data.data?.paypal_order_id,
|
||||||
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// ALWAYS reset loading on error - critical to prevent stuck state
|
// ALWAYS reset loading on error - critical to prevent stuck state
|
||||||
set({ loading: false });
|
set({ loading: false });
|
||||||
|
|||||||
68
frontend/src/utils/currency.ts
Normal file
68
frontend/src/utils/currency.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Currency utilities for formatting amounts with proper symbols
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Currency symbols map
|
||||||
|
const CURRENCY_SYMBOLS: Record<string, string> = {
|
||||||
|
USD: '$',
|
||||||
|
EUR: '€',
|
||||||
|
GBP: '£',
|
||||||
|
INR: '₹',
|
||||||
|
JPY: '¥',
|
||||||
|
CNY: '¥',
|
||||||
|
AUD: 'A$',
|
||||||
|
CAD: 'C$',
|
||||||
|
CHF: 'Fr',
|
||||||
|
SEK: 'kr',
|
||||||
|
NOK: 'kr',
|
||||||
|
DKK: 'kr',
|
||||||
|
PLN: 'zł',
|
||||||
|
BRL: 'R$',
|
||||||
|
ZAR: 'R',
|
||||||
|
AED: 'د.إ',
|
||||||
|
SAR: 'ر.س',
|
||||||
|
PKR: '₨',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get currency symbol for a currency code
|
||||||
|
*/
|
||||||
|
export const getCurrencySymbol = (currencyCode?: string): string => {
|
||||||
|
if (!currencyCode) return '$';
|
||||||
|
const code = currencyCode.toUpperCase();
|
||||||
|
return CURRENCY_SYMBOLS[code] || `${code} `;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an amount with the proper currency symbol
|
||||||
|
* @param amount - The amount to format (string or number)
|
||||||
|
* @param currency - The currency code (e.g., 'USD', 'PKR')
|
||||||
|
* @param showDecimals - Whether to show decimal places (default: true for most currencies)
|
||||||
|
*/
|
||||||
|
export const formatCurrency = (
|
||||||
|
amount: string | number,
|
||||||
|
currency?: string,
|
||||||
|
showDecimals: boolean = true
|
||||||
|
): string => {
|
||||||
|
const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount;
|
||||||
|
if (isNaN(numAmount)) return '-';
|
||||||
|
|
||||||
|
const symbol = getCurrencySymbol(currency);
|
||||||
|
|
||||||
|
// For zero-decimal currencies like JPY, don't show decimals
|
||||||
|
const zeroDecimalCurrencies = ['JPY', 'KRW', 'VND'];
|
||||||
|
const shouldShowDecimals = showDecimals && !zeroDecimalCurrencies.includes(currency?.toUpperCase() || '');
|
||||||
|
|
||||||
|
const formatted = shouldShowDecimals
|
||||||
|
? numAmount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
: numAmount.toLocaleString('en-US', { maximumFractionDigits: 0 });
|
||||||
|
|
||||||
|
return `${symbol}${formatted}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format price for display (typically USD)
|
||||||
|
*/
|
||||||
|
export const formatPrice = (price: string | number): string => {
|
||||||
|
return formatCurrency(price, 'USD');
|
||||||
|
};
|
||||||
@@ -50,3 +50,46 @@ export function formatRelativeDate(dateString: string | Date): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date to a standard display format
|
||||||
|
* @param dateString - ISO date string or Date object
|
||||||
|
* @param options - Intl.DateTimeFormat options
|
||||||
|
* @returns Formatted date string (e.g., "Jan 7, 2026")
|
||||||
|
*/
|
||||||
|
export function formatDate(
|
||||||
|
dateString: string | Date | null | undefined,
|
||||||
|
options: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }
|
||||||
|
): string {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
|
||||||
|
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) return '-';
|
||||||
|
|
||||||
|
return date.toLocaleDateString('en-US', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date and time to a standard display format
|
||||||
|
* @param dateString - ISO date string or Date object
|
||||||
|
* @returns Formatted date and time string (e.g., "Jan 7, 2026, 3:30 PM")
|
||||||
|
*/
|
||||||
|
export function formatDateTime(
|
||||||
|
dateString: string | Date | null | undefined
|
||||||
|
): string {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
|
||||||
|
const date = typeof dateString === 'string' ? new Date(dateString) : dateString;
|
||||||
|
|
||||||
|
if (isNaN(date.getTime())) return '-';
|
||||||
|
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,5 +27,12 @@ export {
|
|||||||
formatDateTime,
|
formatDateTime,
|
||||||
} from './date';
|
} from './date';
|
||||||
|
|
||||||
|
// Currency utilities
|
||||||
|
export {
|
||||||
|
getCurrencySymbol,
|
||||||
|
formatCurrency,
|
||||||
|
formatPrice,
|
||||||
|
} from './currency';
|
||||||
|
|
||||||
// Add other global utilities here as needed
|
// Add other global utilities here as needed
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user