pricign tbale udpated
This commit is contained in:
@@ -111,14 +111,15 @@ class AccountAdminForm(forms.ModelForm):
|
|||||||
@admin.register(Plan)
|
@admin.register(Plan)
|
||||||
class PlanAdmin(admin.ModelAdmin):
|
class PlanAdmin(admin.ModelAdmin):
|
||||||
"""Plan admin - Global, no account filtering needed"""
|
"""Plan admin - Global, no account filtering needed"""
|
||||||
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_content_words', 'included_credits', 'is_active']
|
list_display = ['name', 'slug', 'price', 'billing_cycle', 'max_sites', 'max_users', 'max_keywords', 'max_content_words', 'included_credits', 'is_active', 'is_featured']
|
||||||
list_filter = ['is_active', 'billing_cycle', 'is_internal']
|
list_filter = ['is_active', 'billing_cycle', 'is_internal', 'is_featured']
|
||||||
search_fields = ['name', 'slug']
|
search_fields = ['name', 'slug']
|
||||||
readonly_fields = ['created_at']
|
readonly_fields = ['created_at']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('Plan Info', {
|
('Plan Info', {
|
||||||
'fields': ('name', 'slug', 'price', 'billing_cycle', 'features', 'is_active', 'is_internal')
|
'fields': ('name', 'slug', 'price', 'original_price', 'annual_discount_percent', 'billing_cycle', 'features', 'is_active', 'is_featured', 'is_internal'),
|
||||||
|
'description': 'Price: Current price | Original Price: Crossed-out price (optional) | Annual Discount %: For annual billing | Is Featured: Show as popular/recommended plan'
|
||||||
}),
|
}),
|
||||||
('Account Management Limits', {
|
('Account Management Limits', {
|
||||||
'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles'),
|
'fields': ('max_users', 'max_sites', 'max_industries', 'max_author_profiles'),
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated manually
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('igny8_core_auth', '0014_add_usage_tracking_to_account'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='plan',
|
||||||
|
name='original_price',
|
||||||
|
field=models.DecimalField(
|
||||||
|
blank=True,
|
||||||
|
decimal_places=2,
|
||||||
|
help_text='Original price (before discount) - shows as crossed out price. Leave empty if no discount.',
|
||||||
|
max_digits=10,
|
||||||
|
null=True
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -177,6 +177,13 @@ class Plan(models.Model):
|
|||||||
name = models.CharField(max_length=255)
|
name = models.CharField(max_length=255)
|
||||||
slug = models.SlugField(unique=True, max_length=255)
|
slug = models.SlugField(unique=True, max_length=255)
|
||||||
price = models.DecimalField(max_digits=10, decimal_places=2)
|
price = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
|
original_price = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
help_text="Original price (before discount) - shows as crossed out price. Leave empty if no discount."
|
||||||
|
)
|
||||||
billing_cycle = models.CharField(max_length=20, choices=BILLING_CYCLE_CHOICES, default='monthly')
|
billing_cycle = models.CharField(max_length=20, choices=BILLING_CYCLE_CHOICES, default='monthly')
|
||||||
annual_discount_percent = models.DecimalField(
|
annual_discount_percent = models.DecimalField(
|
||||||
max_digits=5,
|
max_digits=5,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class PlanSerializer(serializers.ModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Plan
|
model = Plan
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'name', 'slug', 'price', 'billing_cycle', 'annual_discount_percent',
|
'id', 'name', 'slug', 'price', 'original_price', 'billing_cycle', 'annual_discount_percent',
|
||||||
'is_featured', 'features', 'is_active',
|
'is_featured', 'features', 'is_active',
|
||||||
'max_users', 'max_sites', 'max_industries', 'max_author_profiles',
|
'max_users', 'max_sites', 'max_industries', 'max_author_profiles',
|
||||||
'max_keywords', 'max_clusters',
|
'max_keywords', 'max_clusters',
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export default function PricingTable({
|
|||||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Save 15% with annual billing
|
Save {plans[0]?.annualDiscountPercent || 15}% with annual billing
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,20 +5,20 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Check } from 'lucide-react';
|
import { Check } from 'lucide-react';
|
||||||
import Button from '../button/Button';
|
|
||||||
import Badge from '../badge/Badge';
|
|
||||||
|
|
||||||
export interface PricingPlan {
|
export interface PricingPlan {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
monthlyPrice: number;
|
monthlyPrice: number;
|
||||||
price: number;
|
price: number;
|
||||||
|
originalPrice?: number;
|
||||||
period: string;
|
period: string;
|
||||||
description: string;
|
description: string;
|
||||||
features: string[];
|
features: string[];
|
||||||
buttonText: string;
|
buttonText: string;
|
||||||
highlighted?: boolean;
|
highlighted?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
annualDiscountPercent?: number;
|
||||||
// Plan limits
|
// Plan limits
|
||||||
max_sites?: number;
|
max_sites?: number;
|
||||||
max_users?: number;
|
max_users?: number;
|
||||||
@@ -44,7 +44,8 @@ export function PricingTable({ variant = '1', title, plans, showToggle = false,
|
|||||||
|
|
||||||
const getPrice = (plan: PricingPlan) => {
|
const getPrice = (plan: PricingPlan) => {
|
||||||
if (billingPeriod === 'annual') {
|
if (billingPeriod === 'annual') {
|
||||||
return (plan.monthlyPrice * 12 * 0.8).toFixed(0); // 20% discount for annual
|
const discount = plan.annualDiscountPercent || 20;
|
||||||
|
return (plan.monthlyPrice * 12 * (100 - discount) / 100).toFixed(0);
|
||||||
}
|
}
|
||||||
return plan.monthlyPrice.toFixed(0);
|
return plan.monthlyPrice.toFixed(0);
|
||||||
};
|
};
|
||||||
@@ -63,6 +64,7 @@ export function PricingTable({ variant = '1', title, plans, showToggle = false,
|
|||||||
|
|
||||||
{showToggle && (
|
{showToggle && (
|
||||||
<div className="flex justify-center mb-8">
|
<div className="flex justify-center mb-8">
|
||||||
|
<div className="inline-flex items-center gap-3">
|
||||||
<div className="inline-flex items-center gap-3 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
<div className="inline-flex items-center gap-3 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||||
<button
|
<button
|
||||||
onClick={() => setBillingPeriod('monthly')}
|
onClick={() => setBillingPeriod('monthly')}
|
||||||
@@ -82,12 +84,15 @@ export function PricingTable({ variant = '1', title, plans, showToggle = false,
|
|||||||
: 'text-gray-600 dark:text-gray-400'
|
: 'text-gray-600 dark:text-gray-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Annual
|
Annually
|
||||||
<Badge className="ml-2 text-xs" color="success">
|
|
||||||
Save 20%
|
|
||||||
</Badge>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{billingPeriod === 'annual' && (
|
||||||
|
<span className="badge-success">
|
||||||
|
Save {Math.round(plans[0]?.annualDiscountPercent || 20)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -97,15 +102,15 @@ export function PricingTable({ variant = '1', title, plans, showToggle = false,
|
|||||||
key={plan.id}
|
key={plan.id}
|
||||||
className={`relative rounded-lg border ${
|
className={`relative rounded-lg border ${
|
||||||
plan.highlighted
|
plan.highlighted
|
||||||
? 'border-primary shadow-lg ring-2 ring-primary ring-opacity-50'
|
? 'pricing-card-featured border-gray-700 shadow-lg ring-2 ring-gray-700'
|
||||||
: 'border-gray-200 dark:border-gray-700'
|
: 'border-gray-200 dark:border-gray-700'
|
||||||
} bg-white dark:bg-gray-800 p-6 flex flex-col`}
|
} ${plan.highlighted ? '' : 'bg-white dark:bg-gray-800'} p-6 flex flex-col`}
|
||||||
>
|
>
|
||||||
{plan.highlighted && (
|
{plan.highlighted && (
|
||||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||||
<Badge color="primary" className="px-3 py-1">
|
<span className="badge-primary px-3 py-1 inline-block">
|
||||||
Popular
|
Popular
|
||||||
</Badge>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -115,15 +120,20 @@ export function PricingTable({ variant = '1', title, plans, showToggle = false,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<div className="flex items-baseline gap-1">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-4xl font-bold text-gray-900 dark:text-white">
|
<span className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-gray-900 dark:text-white'} price-text`}>
|
||||||
${getPrice(plan)}
|
${getPrice(plan)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-gray-600 dark:text-gray-400">{getPeriod()}</span>
|
<span className={plan.highlighted ? 'text-gray-300' : 'text-gray-600 dark:text-gray-400'}>{getPeriod()}</span>
|
||||||
|
{plan.originalPrice && billingPeriod === 'monthly' && (
|
||||||
|
<span className="text-lg line-through text-gray-400">
|
||||||
|
${plan.originalPrice.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{billingPeriod === 'annual' && plan.monthlyPrice > 0 && (
|
{billingPeriod === 'annual' && plan.monthlyPrice > 0 && (
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
Billed ${(plan.monthlyPrice * 12 * 0.8).toFixed(0)}/year
|
Billed ${(plan.monthlyPrice * 12 * (100 - (plan.annualDiscountPercent || 20)) / 100).toFixed(0)}/year
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -192,14 +202,13 @@ export function PricingTable({ variant = '1', title, plans, showToggle = false,
|
|||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<Button
|
<button
|
||||||
variant={plan.highlighted ? 'primary' : 'outline'}
|
className={plan.highlighted ? 'btn-primary' : 'btn-outline'}
|
||||||
className="w-full"
|
|
||||||
onClick={() => onPlanSelect?.(plan)}
|
onClick={() => onPlanSelect?.(plan)}
|
||||||
disabled={plan.disabled}
|
disabled={plan.disabled}
|
||||||
>
|
>
|
||||||
{plan.buttonText}
|
{plan.buttonText}
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
25
frontend/src/components/ui/pricing-table/pricing-table-1.tsx
Normal file
25
frontend/src/components/ui/pricing-table/pricing-table-1.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { PricingTable, PricingPlan } from "./index";
|
||||||
|
|
||||||
|
interface PricingTable1Props {
|
||||||
|
plans: PricingPlan[];
|
||||||
|
title?: string;
|
||||||
|
showToggle?: boolean;
|
||||||
|
onPlanSelect?: (plan: PricingPlan) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PricingTable1({
|
||||||
|
plans,
|
||||||
|
title = "Flexible Plans Tailored to Fit Your Unique Needs!",
|
||||||
|
showToggle = true,
|
||||||
|
onPlanSelect
|
||||||
|
}: PricingTable1Props) {
|
||||||
|
return (
|
||||||
|
<PricingTable
|
||||||
|
variant="1"
|
||||||
|
title={title}
|
||||||
|
plans={plans}
|
||||||
|
showToggle={showToggle}
|
||||||
|
onPlanSelect={onPlanSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,7 +10,8 @@ import {
|
|||||||
import SEO from "../components/SEO";
|
import SEO from "../components/SEO";
|
||||||
import { getMetaTags } from "../config/metaTags";
|
import { getMetaTags } from "../config/metaTags";
|
||||||
import { getPublicPlans } from "../../services/billing.api";
|
import { getPublicPlans } from "../../services/billing.api";
|
||||||
import PricingTable, { PricingPlan } from "../../components/ui/pricing-table/PricingTable";
|
import { PricingTable, PricingPlan } from "../../components/ui/pricing-table";
|
||||||
|
import PricingTable1 from "../../components/ui/pricing-table/pricing-table-1";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
interface Plan {
|
interface Plan {
|
||||||
@@ -47,13 +48,25 @@ const convertToPricingPlan = (plan: Plan): PricingPlan => {
|
|||||||
if (plan.max_users) features.push(`${plan.max_users} Team User${plan.max_users > 1 ? 's' : ''}`);
|
if (plan.max_users) features.push(`${plan.max_users} Team User${plan.max_users > 1 ? 's' : ''}`);
|
||||||
if (plan.included_credits) features.push(`${formatNumber(plan.included_credits)} Monthly Credits`);
|
if (plan.included_credits) features.push(`${formatNumber(plan.included_credits)} Monthly Credits`);
|
||||||
if (plan.max_content_words) features.push(`${formatNumber(plan.max_content_words)} Words/Month`);
|
if (plan.max_content_words) features.push(`${formatNumber(plan.max_content_words)} Words/Month`);
|
||||||
if (plan.max_clusters) features.push(`${plan.max_clusters} AI Keyword Clusters`);
|
if (plan.max_clusters) features.push(`${plan.max_clusters === 999 ? '500' : plan.max_clusters} AI Keyword Clusters`);
|
||||||
if (plan.max_content_ideas) features.push(`${formatNumber(plan.max_content_ideas)} Content Ideas`);
|
if (plan.max_content_ideas) features.push(`${formatNumber(plan.max_content_ideas)} Content Ideas`);
|
||||||
if (plan.max_images_basic && plan.max_images_premium) {
|
if (plan.max_images_basic && plan.max_images_premium) {
|
||||||
features.push(`${formatNumber(plan.max_images_basic)} Basic / ${formatNumber(plan.max_images_premium)} Premium Images`);
|
features.push(`${formatNumber(plan.max_images_basic)} Basic / ${formatNumber(plan.max_images_premium)} Premium Images`);
|
||||||
}
|
}
|
||||||
if (plan.max_image_prompts) features.push(`${formatNumber(plan.max_image_prompts)} Image Prompts`);
|
if (plan.max_image_prompts) features.push(`${formatNumber(plan.max_image_prompts)} Image Prompts`);
|
||||||
|
|
||||||
|
// Custom descriptions based on plan name
|
||||||
|
let description = `Perfect for ${plan.name.toLowerCase()} needs`;
|
||||||
|
if (plan.name.toLowerCase().includes('free')) {
|
||||||
|
description = 'Explore core features risk free';
|
||||||
|
} else if (plan.name.toLowerCase().includes('starter')) {
|
||||||
|
description = 'Launch SEO workflows for small teams';
|
||||||
|
} else if (plan.name.toLowerCase().includes('growth')) {
|
||||||
|
description = 'Scale content production with confidence';
|
||||||
|
} else if (plan.name.toLowerCase().includes('scale')) {
|
||||||
|
description = 'Enterprise power for high volume growth';
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: plan.id,
|
id: plan.id,
|
||||||
name: plan.name,
|
name: plan.name,
|
||||||
@@ -61,9 +74,9 @@ const convertToPricingPlan = (plan: Plan): PricingPlan => {
|
|||||||
price: monthlyPrice,
|
price: monthlyPrice,
|
||||||
annualDiscountPercent: plan.annual_discount_percent || 15,
|
annualDiscountPercent: plan.annual_discount_percent || 15,
|
||||||
period: '/month',
|
period: '/month',
|
||||||
description: `Perfect for ${plan.name.toLowerCase()} needs`,
|
description: description,
|
||||||
features,
|
features,
|
||||||
buttonText: monthlyPrice === 0 ? 'Start Free' : 'Choose Plan',
|
buttonText: monthlyPrice === 0 ? 'Free Trial' : 'Choose Plan',
|
||||||
highlighted: plan.is_featured || false,
|
highlighted: plan.is_featured || false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -190,21 +203,148 @@ const Pricing: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SEO meta={getMetaTags("pricing")} />
|
<SEO meta={getMetaTags("pricing")} />
|
||||||
|
<style>{`
|
||||||
|
/* Pricing Table Component Styles - Embedded for Marketing Site */
|
||||||
|
.pricing-table-wrapper {
|
||||||
|
max-width: 1560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure Tailwind classes work properly */
|
||||||
|
.pricing-table-wrapper * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge component styles */
|
||||||
|
.badge-success {
|
||||||
|
background-color: #10b981;
|
||||||
|
color: white;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-primary {
|
||||||
|
background: linear-gradient(135deg, #0693e3 0%, #0578c2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
display: inline-block;
|
||||||
|
box-shadow: 0 2px 8px rgba(6, 147, 227, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Primary button styles */
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #0693e3 0%, #0578c2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: 0 4px 12px rgba(6, 147, 227, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(6, 147, 227, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outline button styles */
|
||||||
|
.btn-outline {
|
||||||
|
background-color: #1e293b;
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border: 2px solid #334155;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:hover:not(:disabled) {
|
||||||
|
background-color: #334155;
|
||||||
|
border-color: #475569;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:active:not(:disabled) {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Featured plan highlight */
|
||||||
|
.pricing-card-featured {
|
||||||
|
background: linear-gradient(135deg, #1e293b 0%, #334155 100%) !important;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-featured h3,
|
||||||
|
.pricing-card-featured .price-text {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-featured p,
|
||||||
|
.pricing-card-featured span:not(.badge-primary):not(.btn-primary) {
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-featured li span {
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-card-featured .line-through {
|
||||||
|
color: #94a3b8 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Check icon colors */
|
||||||
|
.pricing-card-featured svg {
|
||||||
|
color: #10b981 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Strikethrough price */
|
||||||
|
.line-through {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
<div className="bg-white">
|
<div className="bg-white">
|
||||||
{/* PRICING HERO SECTION */}
|
{/* PRICING HERO SECTION */}
|
||||||
<section className="relative overflow-hidden bg-gradient-to-b from-white via-slate-50/30 to-white">
|
<section className="relative overflow-hidden bg-gradient-to-b from-white via-slate-50/30 to-white">
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(6,147,227,0.02),transparent_60%)]" />
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(6,147,227,0.02),transparent_60%)]" />
|
||||||
|
|
||||||
<div className="relative max-w-4xl mx-auto px-6 py-24 md:py-32 text-center z-10">
|
<div className="relative max-w-4xl mx-auto px-6 py-16 md:py-20 text-center z-10">
|
||||||
<span className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.28em] text-slate-500 bg-slate-100 px-4 py-2 rounded-full mb-6">
|
<span className="inline-flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.28em] text-slate-500 bg-slate-100 px-4 py-2 rounded-full mb-6">
|
||||||
Pricing
|
Pricing
|
||||||
</span>
|
</span>
|
||||||
<h1 className="text-5xl md:text-6xl lg:text-5xl font-bold leading-tight text-slate-900 mb-6">
|
<h1 className="text-4xl md:text-5xl font-bold leading-tight text-slate-900 mb-4">
|
||||||
Simple plans that scale with your automation goals.
|
Simple plans that scale with your automation goals.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xl md:text-2xl text-slate-600 mb-10 max-w-2xl mx-auto leading-relaxed">
|
|
||||||
Flexible pricing for teams of all sizes. No seat limits. No hidden charges. Built for growth.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -232,27 +372,25 @@ const Pricing: React.FC = () => {
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* PRICING TIERS SECTION */}
|
{/* PRICING TABLES SECTION - Dynamic Backend Plans */}
|
||||||
{!loading && !error && (
|
{!loading && !error && plans.length > 0 && (
|
||||||
<section className="mx-auto px-6 py-16" style={{ maxWidth: '1560px' }}>
|
<section className="px-6 py-16">
|
||||||
<PricingTable
|
<div className="pricing-table-wrapper">
|
||||||
variant="1"
|
<PricingTable1
|
||||||
title="Flexible Plans Tailored to Fit Your Unique Needs!"
|
|
||||||
plans={plans.map(convertToPricingPlan)}
|
plans={plans.map(convertToPricingPlan)}
|
||||||
|
title="Choose your plan"
|
||||||
showToggle={true}
|
showToggle={true}
|
||||||
onPlanSelect={(pricingPlan) => {
|
onPlanSelect={(plan) => {
|
||||||
const plan = plans.find(p => p.id === pricingPlan.id);
|
window.location.href = `/signup?plan=${plan.id}`;
|
||||||
if (plan) {
|
|
||||||
window.location.href = `https://app.igny8.com/signup?plan=${plan.slug}`;
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* COMPARISON TABLE SECTION - Keep hardcoded for now */}
|
{/* COMPARISON TABLE SECTION - Keep hardcoded for now */}
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<section className="max-w-7xl mx-auto px-6 pb-24">
|
<section className="max-w-7xl mx-auto px-6 pb-24 pt-16">
|
||||||
<h3 className="text-3xl font-bold text-slate-900 mb-8 text-center">
|
<h3 className="text-3xl font-bold text-slate-900 mb-8 text-center">
|
||||||
Compare plan capabilities
|
Compare plan capabilities
|
||||||
</h3>
|
</h3>
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useState, useEffect } from 'react';
|
|||||||
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 { PricingTable, PricingPlan } from '../../components/ui/pricing-table';
|
import { PricingPlan } from '../../components/ui/pricing-table';
|
||||||
|
import PricingTable1 from '../../components/ui/pricing-table/pricing-table-1';
|
||||||
|
|
||||||
interface Plan {
|
interface Plan {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -86,7 +87,7 @@ const transformPlanToPricingPlan = (plan: Plan, index: number, totalPlans: numbe
|
|||||||
period: '/month',
|
period: '/month',
|
||||||
description: getPlanDescription(plan),
|
description: getPlanDescription(plan),
|
||||||
features: extractFeatures(plan),
|
features: extractFeatures(plan),
|
||||||
buttonText: monthlyPrice === 0 ? 'Start Free' : 'Select Plan',
|
buttonText: monthlyPrice === 0 ? 'Free Trial' : 'Choose Plan',
|
||||||
highlighted: highlighted,
|
highlighted: highlighted,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -94,14 +95,16 @@ const transformPlanToPricingPlan = (plan: Plan, index: number, totalPlans: numbe
|
|||||||
// Get plan description based on plan name or features
|
// Get plan description based on plan name or features
|
||||||
const getPlanDescription = (plan: Plan): string => {
|
const getPlanDescription = (plan: Plan): string => {
|
||||||
const slug = plan.slug.toLowerCase();
|
const slug = plan.slug.toLowerCase();
|
||||||
if (slug.includes('free')) {
|
const name = plan.name.toLowerCase();
|
||||||
return 'Perfect for getting started';
|
|
||||||
} else if (slug.includes('starter')) {
|
if (slug.includes('free') || name.includes('free')) {
|
||||||
return 'For solo designers & freelancers';
|
return 'Explore core features risk free';
|
||||||
} else if (slug.includes('growth')) {
|
} else if (slug.includes('starter') || name.includes('starter')) {
|
||||||
return 'For growing businesses';
|
return 'Launch SEO workflows for small teams';
|
||||||
} else if (slug.includes('scale') || slug.includes('enterprise')) {
|
} else if (slug.includes('growth') || name.includes('growth')) {
|
||||||
return 'For teams and large organizations';
|
return 'Scale content production with confidence';
|
||||||
|
} else if (slug.includes('scale') || slug.includes('enterprise') || name.includes('scale') || name.includes('enterprise')) {
|
||||||
|
return 'Enterprise power for high volume growth';
|
||||||
}
|
}
|
||||||
return 'Choose the perfect plan for your needs';
|
return 'Choose the perfect plan for your needs';
|
||||||
};
|
};
|
||||||
@@ -165,8 +168,7 @@ export default function Plans() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<PricingTable
|
<PricingTable1
|
||||||
variant="1"
|
|
||||||
title="Flexible Plans Tailored to Fit Your Unique Needs!"
|
title="Flexible Plans Tailored to Fit Your Unique Needs!"
|
||||||
plans={pricingPlans}
|
plans={pricingPlans}
|
||||||
showToggle={true}
|
showToggle={true}
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
import ComponentCard from "../../../components/common/ComponentCard";
|
import ComponentCard from "../../../components/common/ComponentCard";
|
||||||
import PageMeta from "../../../components/common/PageMeta";
|
import PageMeta from "../../../components/common/PageMeta";
|
||||||
import { PricingTable, PricingPlan } from "../../../components/ui/pricing-table";
|
import { PricingTable, PricingPlan } from "../../../components/ui/pricing-table";
|
||||||
|
import PricingTable1 from "../../../components/ui/pricing-table/pricing-table-1";
|
||||||
|
import { getPublicPlans } from "../../../services/billing.api";
|
||||||
|
|
||||||
|
interface Plan {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
slug?: string;
|
||||||
|
price: number | string;
|
||||||
|
original_price?: number;
|
||||||
|
annual_discount_percent?: number;
|
||||||
|
is_featured?: boolean;
|
||||||
|
max_sites?: number;
|
||||||
|
max_users?: number;
|
||||||
|
max_keywords?: number;
|
||||||
|
max_clusters?: number;
|
||||||
|
max_content_ideas?: number;
|
||||||
|
max_content_words?: number;
|
||||||
|
max_images_basic?: number;
|
||||||
|
max_images_premium?: number;
|
||||||
|
max_image_prompts?: number;
|
||||||
|
included_credits?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Sample icons for variant 2
|
// Sample icons for variant 2
|
||||||
const PersonIcon = () => (
|
const PersonIcon = () => (
|
||||||
@@ -21,9 +44,95 @@ const StarIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const formatNumber = (num: number | undefined | null): string => {
|
||||||
|
if (!num || num === 0) return '0';
|
||||||
|
if (num >= 1000000) return `${(num / 1000000).toFixed(0)}M`;
|
||||||
|
if (num >= 1000) return `${(num / 1000).toFixed(0)}K`;
|
||||||
|
return num.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const convertToPricingPlan = (plan: Plan): PricingPlan => {
|
||||||
|
const monthlyPrice = typeof plan.price === 'number' ? plan.price : parseFloat(String(plan.price || 0));
|
||||||
|
const features: string[] = [];
|
||||||
|
|
||||||
|
if (plan.max_sites) features.push(`${plan.max_sites === 999999 ? 'Unlimited' : plan.max_sites} Site${plan.max_sites > 1 ? 's' : ''}`);
|
||||||
|
if (plan.max_users) features.push(`${plan.max_users} Team User${plan.max_users > 1 ? 's' : ''}`);
|
||||||
|
if (plan.included_credits) features.push(`${formatNumber(plan.included_credits)} Monthly Credits`);
|
||||||
|
if (plan.max_content_words) features.push(`${formatNumber(plan.max_content_words)} Words/Month`);
|
||||||
|
if (plan.max_clusters) features.push(`${plan.max_clusters} AI Keyword Clusters`);
|
||||||
|
if (plan.max_content_ideas) features.push(`${formatNumber(plan.max_content_ideas)} Content Ideas`);
|
||||||
|
if (plan.max_images_basic && plan.max_images_premium) {
|
||||||
|
features.push(`${formatNumber(plan.max_images_basic)} Basic / ${formatNumber(plan.max_images_premium)} Premium Images`);
|
||||||
|
}
|
||||||
|
if (plan.max_image_prompts) features.push(`${formatNumber(plan.max_image_prompts)} Image Prompts`);
|
||||||
|
|
||||||
|
// Custom descriptions based on plan name
|
||||||
|
let description = `Perfect for ${plan.name.toLowerCase()} needs`;
|
||||||
|
if (plan.name.toLowerCase().includes('free')) {
|
||||||
|
description = 'Explore core features risk free';
|
||||||
|
} else if (plan.name.toLowerCase().includes('starter')) {
|
||||||
|
description = 'Launch SEO workflows for small teams';
|
||||||
|
} else if (plan.name.toLowerCase().includes('growth')) {
|
||||||
|
description = 'Scale content production with confidence';
|
||||||
|
} else if (plan.name.toLowerCase().includes('scale')) {
|
||||||
|
description = 'Enterprise power for high volume growth';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: plan.id,
|
||||||
|
name: plan.name,
|
||||||
|
monthlyPrice: monthlyPrice,
|
||||||
|
price: monthlyPrice,
|
||||||
|
originalPrice: plan.original_price ? (typeof plan.original_price === 'number' ? plan.original_price : parseFloat(String(plan.original_price))) : undefined,
|
||||||
|
period: '/month',
|
||||||
|
description: description,
|
||||||
|
features,
|
||||||
|
buttonText: monthlyPrice === 0 ? 'Free Trial' : 'Choose Plan',
|
||||||
|
highlighted: plan.is_featured || false,
|
||||||
|
annualDiscountPercent: plan.annual_discount_percent || 15,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export default function PricingTablePage() {
|
export default function PricingTablePage() {
|
||||||
|
const [backendPlans, setBackendPlans] = useState<Plan[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPlans = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getPublicPlans();
|
||||||
|
setBackendPlans(data);
|
||||||
|
setLoading(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching plans:', err);
|
||||||
|
setError('Failed to load plans');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchPlans();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Sample plans for variant 1
|
// Sample plans for variant 1
|
||||||
const plans1: PricingPlan[] = [
|
const plans1: PricingPlan[] = [
|
||||||
|
{
|
||||||
|
id: 0,
|
||||||
|
name: 'Free Plan',
|
||||||
|
price: 0.00,
|
||||||
|
period: '/month',
|
||||||
|
description: 'Perfect for free plan needs',
|
||||||
|
features: [
|
||||||
|
'1 Site',
|
||||||
|
'1 Team User',
|
||||||
|
'1K Monthly Credits',
|
||||||
|
'100K Words/Month',
|
||||||
|
'100 AI Keyword Clusters',
|
||||||
|
'300 Content Ideas',
|
||||||
|
'300 Basic / 60 Premium Images',
|
||||||
|
'300 Image Prompts',
|
||||||
|
],
|
||||||
|
buttonText: 'Start Free',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Starter',
|
name: 'Starter',
|
||||||
@@ -205,10 +314,29 @@ export default function PricingTablePage() {
|
|||||||
description="This is React.js Pricing Tables page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
|
description="This is React.js Pricing Tables page for TailAdmin - React.js Tailwind CSS Admin Dashboard Template"
|
||||||
/>
|
/>
|
||||||
<div className="space-y-5 sm:space-y-6">
|
<div className="space-y-5 sm:space-y-6">
|
||||||
|
<ComponentCard title="Pricing Table 1 - Dynamic (Backend Plans)">
|
||||||
|
{loading && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500"></div>
|
||||||
|
<p className="mt-4 text-gray-600 dark:text-gray-400">Loading backend plans...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<p className="text-red-600">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && backendPlans.length > 0 && (
|
||||||
|
<PricingTable1
|
||||||
|
plans={backendPlans.map(convertToPricingPlan)}
|
||||||
|
showToggle={true}
|
||||||
|
onPlanSelect={(plan) => console.log('Selected backend plan:', plan)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ComponentCard>
|
||||||
|
|
||||||
<ComponentCard title="Pricing Table 1">
|
<ComponentCard title="Pricing Table 1">
|
||||||
<PricingTable
|
<PricingTable1
|
||||||
variant="1"
|
|
||||||
title="Flexible Plans Tailored to Fit Your Unique Needs!"
|
|
||||||
plans={plans1}
|
plans={plans1}
|
||||||
showToggle={true}
|
showToggle={true}
|
||||||
onPlanSelect={(plan) => console.log('Selected plan:', plan)}
|
onPlanSelect={(plan) => console.log('Selected plan:', plan)}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import { Card } from '../../components/ui/card';
|
|||||||
import Badge from '../../components/ui/badge/Badge';
|
import Badge from '../../components/ui/badge/Badge';
|
||||||
import Button from '../../components/ui/button/Button';
|
import Button from '../../components/ui/button/Button';
|
||||||
import { useToast } from '../../components/ui/toast/ToastContainer';
|
import { useToast } from '../../components/ui/toast/ToastContainer';
|
||||||
import { PricingTable } from '../../components/ui/pricing-table';
|
import { PricingPlan } from '../../components/ui/pricing-table';
|
||||||
|
import PricingTable1 from '../../components/ui/pricing-table/pricing-table-1';
|
||||||
import CreditCostBreakdownPanel from '../../components/billing/CreditCostBreakdownPanel';
|
import CreditCostBreakdownPanel from '../../components/billing/CreditCostBreakdownPanel';
|
||||||
import CreditCostsPanel from '../../components/billing/CreditCostsPanel';
|
import CreditCostsPanel from '../../components/billing/CreditCostsPanel';
|
||||||
import UsageLimitsPanel from '../../components/billing/UsageLimitsPanel';
|
import UsageLimitsPanel from '../../components/billing/UsageLimitsPanel';
|
||||||
@@ -716,9 +717,9 @@ export default function PlansAndBillingPage() {
|
|||||||
Select the plan that best fits your needs
|
Select the plan that best fits your needs
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mx-auto" style={{ maxWidth: '1200px' }}>
|
<div className="mx-auto" style={{ maxWidth: '1560px' }}>
|
||||||
<PricingTable
|
<PricingTable1
|
||||||
variant="1"
|
title=""
|
||||||
plans={plans
|
plans={plans
|
||||||
.filter(plan => {
|
.filter(plan => {
|
||||||
// Only show paid plans (exclude Free Plan)
|
// Only show paid plans (exclude Free Plan)
|
||||||
@@ -728,29 +729,42 @@ export default function PlansAndBillingPage() {
|
|||||||
})
|
})
|
||||||
.map(plan => {
|
.map(plan => {
|
||||||
const discount = plan.annual_discount_percent || 15;
|
const discount = plan.annual_discount_percent || 15;
|
||||||
|
|
||||||
|
// Get custom description based on plan name
|
||||||
|
let description = 'Standard plan';
|
||||||
|
const planName = plan.name.toLowerCase();
|
||||||
|
if (planName.includes('starter')) {
|
||||||
|
description = 'Launch SEO workflows for small teams';
|
||||||
|
} else if (planName.includes('growth')) {
|
||||||
|
description = 'Scale content production with confidence';
|
||||||
|
} else if (planName.includes('scale')) {
|
||||||
|
description = 'Enterprise power for high volume growth';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build features array
|
||||||
|
const features: string[] = [];
|
||||||
|
if (plan.max_sites) features.push(`${plan.max_sites === 999999 ? 'Unlimited' : plan.max_sites} Site${plan.max_sites > 1 ? 's' : ''}`);
|
||||||
|
if (plan.max_users) features.push(`${plan.max_users} Team User${plan.max_users > 1 ? 's' : ''}`);
|
||||||
|
if (plan.included_credits) features.push(`${(plan.included_credits / 1000).toFixed(0)}K Monthly Credits`);
|
||||||
|
if (plan.max_content_words) features.push(`${(plan.max_content_words / 1000).toFixed(0)}K Words/Month`);
|
||||||
|
if (plan.max_clusters) features.push(`${plan.max_clusters} AI Keyword Clusters`);
|
||||||
|
if (plan.max_content_ideas) features.push(`${plan.max_content_ideas} Content Ideas`);
|
||||||
|
if (plan.max_images_basic && plan.max_images_premium) {
|
||||||
|
features.push(`${plan.max_images_basic} Basic / ${plan.max_images_premium} Premium Images`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: plan.id,
|
id: plan.id,
|
||||||
name: plan.name,
|
name: plan.name,
|
||||||
monthlyPrice: plan.price || 0,
|
monthlyPrice: plan.price || 0,
|
||||||
price: plan.price || 0,
|
price: plan.price || 0,
|
||||||
annualDiscountPercent: discount,
|
annualDiscountPercent: discount,
|
||||||
period: `/${plan.interval || 'month'}`,
|
period: '/month',
|
||||||
description: plan.description || 'Standard plan',
|
description: description,
|
||||||
features: plan.features && plan.features.length > 0
|
features: features.length > 0 ? features : ['Monthly credits included', 'Module access', 'Email support'],
|
||||||
? plan.features
|
buttonText: plan.id === currentPlanId ? 'Current Plan' : 'Choose Plan',
|
||||||
: ['Monthly credits included', 'Module access', 'Email support'],
|
|
||||||
buttonText: plan.id === currentPlanId ? 'Current Plan' : 'Select Plan',
|
|
||||||
highlighted: plan.is_featured || false,
|
highlighted: plan.is_featured || false,
|
||||||
disabled: plan.id === currentPlanId || planLoadingId === plan.id,
|
disabled: plan.id === currentPlanId || planLoadingId === plan.id,
|
||||||
max_sites: plan.max_sites,
|
|
||||||
max_users: plan.max_users,
|
|
||||||
max_keywords: plan.max_keywords,
|
|
||||||
max_clusters: plan.max_clusters,
|
|
||||||
max_content_ideas: plan.max_content_ideas,
|
|
||||||
max_content_words: plan.max_content_words,
|
|
||||||
max_images_basic: plan.max_images_basic,
|
|
||||||
max_images_premium: plan.max_images_premium,
|
|
||||||
included_credits: plan.included_credits,
|
|
||||||
};
|
};
|
||||||
})}
|
})}
|
||||||
showToggle={true}
|
showToggle={true}
|
||||||
|
|||||||
@@ -856,6 +856,7 @@ export interface Plan {
|
|||||||
name: string;
|
name: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
price?: number | string;
|
price?: number | string;
|
||||||
|
original_price?: number;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
interval?: 'month' | 'year';
|
interval?: 'month' | 'year';
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user