mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
feat: products crud
This commit is contained in:
parent
7d81afc396
commit
4c8cb42978
12 changed files with 677 additions and 39 deletions
|
|
@ -1,21 +1,22 @@
|
|||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Literal, Optional
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import JSON
|
||||
from sqlmodel import Field, SQLModel, Column, BigInteger, ForeignKey
|
||||
|
||||
|
||||
# Stripe provider config
|
||||
class StripeProviderConfig(BaseModel):
|
||||
stripe_key: str = ""
|
||||
stripe_secret_key: str = ""
|
||||
stripe_webhook_secret: str = ""
|
||||
|
||||
# PaymentsConfig
|
||||
class PaymentProviderEnum(str, Enum):
|
||||
STRIPE = "stripe"
|
||||
|
||||
class PaymentsConfigBase(SQLModel):
|
||||
enabled: bool = False
|
||||
enabled: bool = True
|
||||
provider: PaymentProviderEnum = PaymentProviderEnum.STRIPE
|
||||
provider_config: dict = Field(default={}, sa_column=Column(JSON))
|
||||
|
||||
|
|
@ -34,7 +35,7 @@ class PaymentsConfigCreate(PaymentsConfigBase):
|
|||
|
||||
|
||||
class PaymentsConfigUpdate(PaymentsConfigBase):
|
||||
enabled: Optional[bool] = False
|
||||
enabled: Optional[bool] = True
|
||||
provider_config: Optional[dict] = None
|
||||
|
||||
|
||||
|
|
|
|||
14
apps/api/src/db/payments/payments_courses.py
Normal file
14
apps/api/src/db/payments/payments_courses.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from enum import Enum
|
||||
from sqlmodel import SQLModel, Field, Column, BigInteger, ForeignKey, String, JSON
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
class PaymentCourseBase(SQLModel):
|
||||
course_id: int = Field(sa_column=Column(BigInteger, ForeignKey("course.id", ondelete="CASCADE")))
|
||||
payment_product_id: int = Field(sa_column=Column(BigInteger, ForeignKey("paymentsproduct.id", ondelete="CASCADE")))
|
||||
org_id: int = Field(sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE")))
|
||||
|
||||
class PaymentCourse(PaymentCourseBase, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
creation_date: datetime = Field(default=datetime.now())
|
||||
update_date: datetime = Field(default=datetime.now())
|
||||
38
apps/api/src/db/payments/payments_products.py
Normal file
38
apps/api/src/db/payments/payments_products.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from enum import Enum
|
||||
from sqlmodel import SQLModel, Field, Column, BigInteger, ForeignKey, String, JSON
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
class PaymentProductTypeEnum(str, Enum):
|
||||
SUBSCRIPTION = "subscription"
|
||||
ONE_TIME = "one_time"
|
||||
|
||||
class PaymentsProductBase(SQLModel):
|
||||
name: str = ""
|
||||
description: Optional[str] = ""
|
||||
product_type: PaymentProductTypeEnum = PaymentProductTypeEnum.ONE_TIME
|
||||
benefits: str = ""
|
||||
amount: float = 0.0
|
||||
|
||||
class PaymentsProduct(PaymentsProductBase, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
org_id: int = Field(
|
||||
sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE"))
|
||||
)
|
||||
payments_config_id: int = Field(sa_column=Column(BigInteger, ForeignKey("paymentsconfig.id", ondelete="CASCADE")))
|
||||
provider_product_id: str = Field(sa_column=Column(String))
|
||||
creation_date: datetime = Field(default=datetime.now())
|
||||
update_date: datetime = Field(default=datetime.now())
|
||||
|
||||
class PaymentsProductCreate(PaymentsProductBase):
|
||||
pass
|
||||
|
||||
class PaymentsProductUpdate(PaymentsProductBase):
|
||||
pass
|
||||
|
||||
class PaymentsProductRead(PaymentsProductBase):
|
||||
id: int
|
||||
org_id: int
|
||||
payments_config_id: int
|
||||
creation_date: datetime
|
||||
update_date: datetime
|
||||
19
apps/api/src/db/payments/payments_users.py
Normal file
19
apps/api/src/db/payments/payments_users.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from enum import Enum
|
||||
from sqlmodel import SQLModel, Field, Column, BigInteger, ForeignKey, String, JSON
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
class PaymentUserStatusEnum(str, Enum):
|
||||
ACTIVE = "active"
|
||||
INACTIVE = "inactive"
|
||||
|
||||
class PaymentsUserBase(SQLModel):
|
||||
user_id: int = Field(sa_column=Column(BigInteger, ForeignKey("user.id", ondelete="CASCADE")))
|
||||
status: PaymentUserStatusEnum = PaymentUserStatusEnum.ACTIVE
|
||||
payment_product_id: int = Field(sa_column=Column(BigInteger, ForeignKey("paymentsproduct.id", ondelete="CASCADE")))
|
||||
org_id: int = Field(sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE")))
|
||||
|
||||
class PaymentsUser(PaymentsUserBase, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
creation_date: datetime = Field(default=datetime.now())
|
||||
update_date: datetime = Field(default=datetime.now())
|
||||
|
|
@ -10,6 +10,9 @@ from src.services.payments.payments import (
|
|||
update_payments_config,
|
||||
delete_payments_config,
|
||||
)
|
||||
from src.db.payments.payments_products import PaymentsProduct, PaymentsProductCreate, PaymentsProductRead, PaymentsProductUpdate
|
||||
from src.services.payments.payments_products import create_payments_product, delete_payments_product, get_payments_product, list_payments_products, update_payments_product
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
|
@ -51,3 +54,54 @@ async def api_delete_payments_config(
|
|||
):
|
||||
await delete_payments_config(request, org_id, current_user, db_session)
|
||||
return {"message": "Payments config deleted successfully"}
|
||||
|
||||
@router.post("/{org_id}/products")
|
||||
async def api_create_payments_product(
|
||||
request: Request,
|
||||
org_id: int,
|
||||
payments_product: PaymentsProductCreate,
|
||||
current_user: PublicUser = Depends(get_current_user),
|
||||
db_session: Session = Depends(get_db_session),
|
||||
) -> PaymentsProductRead:
|
||||
return await create_payments_product(request, org_id, payments_product, current_user, db_session)
|
||||
|
||||
@router.get("/{org_id}/products")
|
||||
async def api_get_payments_products(
|
||||
request: Request,
|
||||
org_id: int,
|
||||
current_user: PublicUser = Depends(get_current_user),
|
||||
db_session: Session = Depends(get_db_session),
|
||||
) -> list[PaymentsProductRead]:
|
||||
return await list_payments_products(request, org_id, current_user, db_session)
|
||||
|
||||
@router.get("/{org_id}/products/{product_id}")
|
||||
async def api_get_payments_product(
|
||||
request: Request,
|
||||
org_id: int,
|
||||
product_id: int,
|
||||
current_user: PublicUser = Depends(get_current_user),
|
||||
db_session: Session = Depends(get_db_session),
|
||||
) -> PaymentsProductRead:
|
||||
return await get_payments_product(request, org_id, product_id, current_user, db_session)
|
||||
|
||||
@router.put("/{org_id}/products/{product_id}")
|
||||
async def api_update_payments_product(
|
||||
request: Request,
|
||||
org_id: int,
|
||||
product_id: int,
|
||||
payments_product: PaymentsProductUpdate,
|
||||
current_user: PublicUser = Depends(get_current_user),
|
||||
db_session: Session = Depends(get_db_session),
|
||||
) -> PaymentsProductRead:
|
||||
return await update_payments_product(request, org_id, product_id, payments_product, current_user, db_session)
|
||||
|
||||
@router.delete("/{org_id}/products/{product_id}")
|
||||
async def api_delete_payments_product(
|
||||
request: Request,
|
||||
org_id: int,
|
||||
product_id: int,
|
||||
current_user: PublicUser = Depends(get_current_user),
|
||||
db_session: Session = Depends(get_db_session),
|
||||
):
|
||||
await delete_payments_product(request, org_id, product_id, current_user, db_session)
|
||||
return {"message": "Payments product deleted successfully"}
|
||||
|
|
|
|||
153
apps/api/src/services/payments/payments_products.py
Normal file
153
apps/api/src/services/payments/payments_products.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
from fastapi import HTTPException, Request
|
||||
from sqlmodel import Session, select
|
||||
from src.db.payments.payments import PaymentsConfig
|
||||
from src.db.payments.payments_products import (
|
||||
PaymentsProduct,
|
||||
PaymentsProductCreate,
|
||||
PaymentsProductUpdate,
|
||||
PaymentsProductRead,
|
||||
)
|
||||
from src.db.users import PublicUser, AnonymousUser
|
||||
from src.db.organizations import Organization
|
||||
from src.services.orgs.orgs import rbac_check
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
async def create_payments_product(
|
||||
request: Request,
|
||||
org_id: int,
|
||||
payments_product: PaymentsProductCreate,
|
||||
current_user: PublicUser | AnonymousUser,
|
||||
db_session: Session,
|
||||
) -> PaymentsProductRead:
|
||||
# Check if organization exists
|
||||
statement = select(Organization).where(Organization.id == org_id)
|
||||
org = db_session.exec(statement).first()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Organization not found")
|
||||
|
||||
# RBAC check
|
||||
await rbac_check(request, org.org_uuid, current_user, "create", db_session)
|
||||
|
||||
# Check if payments config exists and has a valid id
|
||||
statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id)
|
||||
config = db_session.exec(statement).first()
|
||||
if not config or config.id is None:
|
||||
raise HTTPException(status_code=404, detail="Valid payments config not found")
|
||||
|
||||
# Create new payments product
|
||||
new_product = PaymentsProduct(**payments_product.model_dump(), org_id=org_id, payments_config_id=config.id)
|
||||
new_product.creation_date = datetime.now()
|
||||
new_product.update_date = datetime.now()
|
||||
|
||||
db_session.add(new_product)
|
||||
db_session.commit()
|
||||
db_session.refresh(new_product)
|
||||
|
||||
return PaymentsProductRead.model_validate(new_product)
|
||||
|
||||
async def get_payments_product(
|
||||
request: Request,
|
||||
org_id: int,
|
||||
product_id: int,
|
||||
current_user: PublicUser | AnonymousUser,
|
||||
db_session: Session,
|
||||
) -> PaymentsProductRead:
|
||||
# Check if organization exists
|
||||
statement = select(Organization).where(Organization.id == org_id)
|
||||
org = db_session.exec(statement).first()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Organization not found")
|
||||
|
||||
# RBAC check
|
||||
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
|
||||
|
||||
# Get payments product
|
||||
statement = select(PaymentsProduct).where(PaymentsProduct.id == product_id, PaymentsProduct.org_id == org_id)
|
||||
product = db_session.exec(statement).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Payments product not found")
|
||||
|
||||
return PaymentsProductRead.model_validate(product)
|
||||
|
||||
async def update_payments_product(
|
||||
request: Request,
|
||||
org_id: int,
|
||||
product_id: int,
|
||||
payments_product: PaymentsProductUpdate,
|
||||
current_user: PublicUser | AnonymousUser,
|
||||
db_session: Session,
|
||||
) -> PaymentsProductRead:
|
||||
# Check if organization exists
|
||||
statement = select(Organization).where(Organization.id == org_id)
|
||||
org = db_session.exec(statement).first()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Organization not found")
|
||||
|
||||
# RBAC check
|
||||
await rbac_check(request, org.org_uuid, current_user, "update", db_session)
|
||||
|
||||
# Get existing payments product
|
||||
statement = select(PaymentsProduct).where(PaymentsProduct.id == product_id, PaymentsProduct.org_id == org_id)
|
||||
product = db_session.exec(statement).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Payments product not found")
|
||||
|
||||
# Update product
|
||||
for key, value in payments_product.model_dump().items():
|
||||
setattr(product, key, value)
|
||||
|
||||
product.update_date = datetime.now()
|
||||
|
||||
db_session.add(product)
|
||||
db_session.commit()
|
||||
db_session.refresh(product)
|
||||
|
||||
return PaymentsProductRead.model_validate(product)
|
||||
|
||||
async def delete_payments_product(
|
||||
request: Request,
|
||||
org_id: int,
|
||||
product_id: int,
|
||||
current_user: PublicUser | AnonymousUser,
|
||||
db_session: Session,
|
||||
) -> None:
|
||||
# Check if organization exists
|
||||
statement = select(Organization).where(Organization.id == org_id)
|
||||
org = db_session.exec(statement).first()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Organization not found")
|
||||
|
||||
# RBAC check
|
||||
await rbac_check(request, org.org_uuid, current_user, "delete", db_session)
|
||||
|
||||
# Get existing payments product
|
||||
statement = select(PaymentsProduct).where(PaymentsProduct.id == product_id, PaymentsProduct.org_id == org_id)
|
||||
product = db_session.exec(statement).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="Payments product not found")
|
||||
|
||||
# Delete product
|
||||
db_session.delete(product)
|
||||
db_session.commit()
|
||||
|
||||
async def list_payments_products(
|
||||
request: Request,
|
||||
org_id: int,
|
||||
current_user: PublicUser | AnonymousUser,
|
||||
db_session: Session,
|
||||
) -> list[PaymentsProductRead]:
|
||||
# Check if organization exists
|
||||
statement = select(Organization).where(Organization.id == org_id)
|
||||
org = db_session.exec(statement).first()
|
||||
if not org:
|
||||
raise HTTPException(status_code=404, detail="Organization not found")
|
||||
|
||||
# RBAC check
|
||||
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
|
||||
|
||||
# Get payments products ordered by id
|
||||
statement = select(PaymentsProduct).where(PaymentsProduct.org_id == org_id).order_by(PaymentsProduct.id.desc())
|
||||
products = db_session.exec(statement).all()
|
||||
|
||||
return [PaymentsProductRead.model_validate(product) for product in products]
|
||||
0
apps/api/src/services/payments/products.py
Normal file
0
apps/api/src/services/payments/products.py
Normal file
|
|
@ -8,6 +8,7 @@ import { CreditCard, Settings, Repeat, BookOpen, Users, DollarSign } from 'lucid
|
|||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import PaymentsConfigurationPage from '@components/Dashboard/Payments/PaymentsConfigurationPage'
|
||||
import PaymentsProductPage from '@components/Dashboard/Payments/PaymentsProductPage'
|
||||
|
||||
|
||||
|
||||
|
|
@ -40,9 +41,9 @@ function PaymentsPage({ params }: { params: PaymentsParams }) {
|
|||
setH1Label('Subscriptions')
|
||||
setH2Label('Manage your subscription plans')
|
||||
}
|
||||
if (selectedSubPage === 'paid-courses') {
|
||||
setH1Label('Paid Courses')
|
||||
setH2Label('Manage your paid courses and pricing')
|
||||
if (selectedSubPage === 'paid-products') {
|
||||
setH1Label('Paid Products')
|
||||
setH2Label('Manage your paid products and pricing')
|
||||
}
|
||||
if (selectedSubPage === 'customers') {
|
||||
setH1Label('Customers')
|
||||
|
|
@ -66,18 +67,18 @@ function PaymentsPage({ params }: { params: PaymentsParams }) {
|
|||
</div>
|
||||
<div className="flex space-x-5 font-black text-sm">
|
||||
<TabLink
|
||||
href={getUriWithOrg(params.orgslug, '/dash/payments/general')}
|
||||
icon={<CreditCard size={16} />}
|
||||
label="General"
|
||||
isActive={selectedSubPage === 'general'}
|
||||
onClick={() => setSelectedSubPage('general')}
|
||||
href={getUriWithOrg(params.orgslug, '/dash/payments/customers')}
|
||||
icon={<Users size={16} />}
|
||||
label="Customers"
|
||||
isActive={selectedSubPage === 'customers'}
|
||||
onClick={() => setSelectedSubPage('customers')}
|
||||
/>
|
||||
<TabLink
|
||||
href={getUriWithOrg(params.orgslug, '/dash/payments/configuration')}
|
||||
icon={<Settings size={16} />}
|
||||
label="Configuration"
|
||||
isActive={selectedSubPage === 'configuration'}
|
||||
onClick={() => setSelectedSubPage('configuration')}
|
||||
href={getUriWithOrg(params.orgslug, '/dash/payments/paid-products')}
|
||||
icon={<BookOpen size={16} />}
|
||||
label="One-time Products"
|
||||
isActive={selectedSubPage === 'paid-products'}
|
||||
onClick={() => setSelectedSubPage('paid-products')}
|
||||
/>
|
||||
<TabLink
|
||||
href={getUriWithOrg(params.orgslug, '/dash/payments/subscriptions')}
|
||||
|
|
@ -87,19 +88,13 @@ function PaymentsPage({ params }: { params: PaymentsParams }) {
|
|||
onClick={() => setSelectedSubPage('subscriptions')}
|
||||
/>
|
||||
<TabLink
|
||||
href={getUriWithOrg(params.orgslug, '/dash/payments/paid-courses')}
|
||||
icon={<BookOpen size={16} />}
|
||||
label="Paid Courses"
|
||||
isActive={selectedSubPage === 'paid-courses'}
|
||||
onClick={() => setSelectedSubPage('paid-courses')}
|
||||
/>
|
||||
<TabLink
|
||||
href={getUriWithOrg(params.orgslug, '/dash/payments/customers')}
|
||||
icon={<Users size={16} />}
|
||||
label="Customers"
|
||||
isActive={selectedSubPage === 'customers'}
|
||||
onClick={() => setSelectedSubPage('customers')}
|
||||
href={getUriWithOrg(params.orgslug, '/dash/payments/configuration')}
|
||||
icon={<Settings size={16} />}
|
||||
label="Configuration"
|
||||
isActive={selectedSubPage === 'configuration'}
|
||||
onClick={() => setSelectedSubPage('configuration')}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6"></div>
|
||||
|
|
@ -112,8 +107,8 @@ function PaymentsPage({ params }: { params: PaymentsParams }) {
|
|||
>
|
||||
{selectedSubPage === 'general' && <div>General</div>}
|
||||
{selectedSubPage === 'configuration' && <PaymentsConfigurationPage />}
|
||||
{selectedSubPage === 'paid-products' && <PaymentsProductPage />}
|
||||
{selectedSubPage === 'subscriptions' && <div>Subscriptions</div>}
|
||||
{selectedSubPage === 'paid-courses' && <div>Paid Courses</div>}
|
||||
{selectedSubPage === 'customers' && <div>Customers</div>}
|
||||
</motion.div>
|
||||
</div>
|
||||
|
|
@ -124,9 +119,8 @@ const TabLink = ({ href, icon, label, isActive, onClick }: { href: string, icon:
|
|||
<Link href={href}>
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`py-2 w-fit text-center border-black transition-all ease-linear ${
|
||||
isActive ? 'border-b-4' : 'opacity-50'
|
||||
} cursor-pointer`}
|
||||
className={`py-2 w-fit text-center border-black transition-all ease-linear ${isActive ? 'border-b-4' : 'opacity-50'
|
||||
} cursor-pointer`}
|
||||
>
|
||||
<div className="flex items-center space-x-2.5 mx-2">
|
||||
{icon}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,13 @@
|
|||
'use client'
|
||||
import PageLoading from '@components/Objects/Loaders/PageLoading';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import React, { useContext, createContext, useEffect } from 'react'
|
||||
import React, { useContext, createContext } from 'react'
|
||||
|
||||
export const SessionContext = createContext({}) as any
|
||||
|
||||
function LHSessionProvider({ children }: { children: React.ReactNode }) {
|
||||
const session = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
}, [])
|
||||
|
||||
|
||||
if (session && session.status == 'loading') {
|
||||
return <PageLoading />
|
||||
}
|
||||
|
|
|
|||
180
apps/web/components/Dashboard/Payments/PaymentsProductPage.tsx
Normal file
180
apps/web/components/Dashboard/Payments/PaymentsProductPage.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
'use client';
|
||||
import React, { useState } from 'react'
|
||||
import { useOrg } from '@components/Contexts/OrgContext';
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext';
|
||||
import useSWR, { mutate } from 'swr';
|
||||
import { getProducts, deleteProduct, updateProduct } from '@services/payments/products';
|
||||
import CreateProductForm from '@components/Dashboard/Payments/SubComponents/CreateProductForm';
|
||||
import { Plus, Trash2, Pencil, DollarSign, Info } from 'lucide-react';
|
||||
import Modal from '@components/StyledElements/Modal/Modal';
|
||||
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal';
|
||||
import toast from 'react-hot-toast';
|
||||
import Link from 'next/link';
|
||||
import { getUriWithOrg } from '@services/config/config';
|
||||
|
||||
function PaymentsProductPage() {
|
||||
const org = useOrg() as any;
|
||||
const session = useLHSession() as any;
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingProductId, setEditingProductId] = useState<string | null>(null);
|
||||
|
||||
const { data: products, error } = useSWR(
|
||||
() => org && session ? [`/payments/${org.id}/products`, session.data?.tokens?.access_token] : null,
|
||||
([url, token]) => getProducts(org.id, token)
|
||||
);
|
||||
|
||||
const handleDeleteProduct = async (productId: string) => {
|
||||
try {
|
||||
await deleteProduct(org.id, productId, session.data?.tokens?.access_token);
|
||||
mutate([`/payments/${org.id}/products`, session.data?.tokens?.access_token]);
|
||||
toast.success('Product deleted successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete product');
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <div>Failed to load products</div>;
|
||||
if (!products) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-[#f8f8f8]">
|
||||
<div className="pl-10 pr-10 mx-auto">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Products</h1>
|
||||
<button
|
||||
onClick={() => setIsCreateModalOpen(true)}
|
||||
className="mb-4 flex items-center space-x-2 px-2 py-1.5 rounded-md bg-gradient-to-bl text-gray-800 font-medium from-gray-400/50 to-gray-200/80 border border-gray-600/10 shadow-gray-900/10 shadow-lg hover:from-gray-300/50 hover:to-gray-100/80 transition duration-300"
|
||||
>
|
||||
<Plus size={18} />
|
||||
<span className="text-sm font-bold">Create New Product</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isDialogOpen={isCreateModalOpen}
|
||||
onOpenChange={setIsCreateModalOpen}
|
||||
dialogTitle="Create New Product"
|
||||
dialogDescription="Add a new product to your organization"
|
||||
dialogContent={
|
||||
<CreateProductForm onSuccess={() => setIsCreateModalOpen(false)} />
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{products.data.map((product: any) => (
|
||||
<div key={product.id} className="bg-white p-4 rounded-lg nice-shadow">
|
||||
{editingProductId === product.id ? (
|
||||
<EditProductForm
|
||||
product={product}
|
||||
onSuccess={() => setEditingProductId(null)}
|
||||
onCancel={() => setEditingProductId(null)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<h3 className="font-bold text-lg">{product.name}</h3>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setEditingProductId(product.id)}
|
||||
className="text-blue-500 hover:text-blue-700"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</button>
|
||||
<ConfirmationModal
|
||||
confirmationButtonText="Delete Product"
|
||||
confirmationMessage="Are you sure you want to delete this product?"
|
||||
dialogTitle={`Delete ${product.name}?`}
|
||||
dialogTrigger={
|
||||
<button className="text-red-500 hover:text-red-700">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
}
|
||||
functionToExecute={() => handleDeleteProduct(product.id)}
|
||||
status="warning"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600">{product.description}</p>
|
||||
<p className="mt-2 font-semibold">${product.amount.toFixed(2)}</p>
|
||||
<p className="text-sm text-gray-500">{product.product_type}</p>
|
||||
{product.benefits && (
|
||||
<div className="mt-2">
|
||||
<h4 className="font-semibold text-sm">Benefits:</h4>
|
||||
<p className="text-sm text-gray-600">{product.benefits}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{products.data.length === 0 && (
|
||||
<div className="flex mx-auto space-x-2 font-semibold mt-3 text-gray-600 items-center">
|
||||
<Info size={20} />
|
||||
<p>No products available. Create a new product to get started.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const EditProductForm = ({ product, onSuccess, onCancel }: { product: any, onSuccess: () => void, onCancel: () => void }) => {
|
||||
const [name, setName] = useState(product.name);
|
||||
const [description, setDescription] = useState(product.description);
|
||||
const [amount, setAmount] = useState(product.amount);
|
||||
const [benefits, setBenefits] = useState(product.benefits || '');
|
||||
const org = useOrg() as any;
|
||||
const session = useLHSession() as any;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await updateProduct(org.id, product.id, { name, description, amount, benefits }, session.data?.tokens?.access_token);
|
||||
mutate([`/payments/${org.id}/products`, session.data?.tokens?.access_token]);
|
||||
onSuccess();
|
||||
toast.success('Product updated successfully');
|
||||
} catch (error) {
|
||||
toast.error('Failed to update product');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full p-2 border rounded"
|
||||
placeholder="Product Name"
|
||||
/>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full p-2 border rounded"
|
||||
placeholder="Product Description"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(parseFloat(e.target.value))}
|
||||
className="w-full p-2 border rounded"
|
||||
placeholder="Price"
|
||||
step="0.01"
|
||||
/>
|
||||
<textarea
|
||||
value={benefits}
|
||||
onChange={(e) => setBenefits(e.target.value)}
|
||||
className="w-full p-2 border rounded"
|
||||
placeholder="Product Benefits"
|
||||
/>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button type="button" onClick={onCancel} className="px-4 py-2 text-gray-600 border rounded">Cancel</button>
|
||||
<button type="submit" className="px-4 py-2 text-white bg-blue-500 rounded">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentsProductPage
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useOrg } from '@components/Contexts/OrgContext';
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext';
|
||||
import { createProduct } from '@services/payments/products';
|
||||
import FormLayout, { ButtonBlack, Input, Textarea, FormField, FormLabelAndMessage, Flex } from '@components/StyledElements/Form/Form';
|
||||
import * as Form from '@radix-ui/react-form';
|
||||
import { useFormik } from 'formik';
|
||||
import toast from 'react-hot-toast';
|
||||
import { mutate } from 'swr';
|
||||
import { getAPIUrl } from '@services/config/config';
|
||||
|
||||
interface ProductFormValues {
|
||||
name: string;
|
||||
description: string;
|
||||
product_type: string;
|
||||
benefits: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
const CreateProductForm: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
|
||||
const org = useOrg() as any;
|
||||
const session = useLHSession() as any;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const validate = (values: any) => {
|
||||
const errors: any = {};
|
||||
|
||||
if (!values.name) {
|
||||
errors.name = 'Required';
|
||||
}
|
||||
|
||||
if (!values.description) {
|
||||
errors.description = 'Required';
|
||||
}
|
||||
|
||||
if (!values.amount) {
|
||||
errors.amount = 'Required';
|
||||
} else {
|
||||
const numAmount = Number(values.amount);
|
||||
if (isNaN(numAmount) || numAmount <= 0) {
|
||||
errors.amount = 'Amount must be greater than 0';
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
const formik = useFormik<ProductFormValues>({
|
||||
initialValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
product_type: 'one_time',
|
||||
benefits: '',
|
||||
amount: 0,
|
||||
},
|
||||
validate,
|
||||
onSubmit: async (values) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const res = await createProduct(org.id, values, session.data?.tokens?.access_token);
|
||||
if (res.success) {
|
||||
toast.success('Product created successfully');
|
||||
mutate([`/payments/${org.id}/products`, session.data?.tokens?.access_token]);
|
||||
formik.resetForm();
|
||||
onSuccess(); // Call the onSuccess function to close the modal
|
||||
} else {
|
||||
toast.error('Failed to create product');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating product:', error);
|
||||
toast.error('An error occurred while creating the product');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<FormLayout onSubmit={formik.handleSubmit}>
|
||||
<FormField name="name">
|
||||
<FormLabelAndMessage label="Product Name" message={formik.errors.name} />
|
||||
<Form.Control asChild>
|
||||
<Input
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.name}
|
||||
type="text"
|
||||
required
|
||||
/>
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<FormField name="description">
|
||||
<FormLabelAndMessage label="Description" message={formik.errors.description} />
|
||||
<Form.Control asChild>
|
||||
<Textarea
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.description}
|
||||
required
|
||||
/>
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<FormField name="benefits">
|
||||
<FormLabelAndMessage label="Benefits" />
|
||||
<Form.Control asChild>
|
||||
<Textarea
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.benefits}
|
||||
/>
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
|
||||
<FormField name="amount">
|
||||
<FormLabelAndMessage label="Amount" message={formik.errors.amount} />
|
||||
<Form.Control asChild>
|
||||
<Input
|
||||
onChange={formik.handleChange}
|
||||
value={formik.values.amount}
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
required
|
||||
/>
|
||||
</Form.Control>
|
||||
</FormField>
|
||||
<Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}>
|
||||
<Form.Submit asChild>
|
||||
<ButtonBlack type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create Product'}
|
||||
</ButtonBlack>
|
||||
</Form.Submit>
|
||||
</Flex>
|
||||
</FormLayout>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateProductForm;
|
||||
50
apps/web/services/payments/products.ts
Normal file
50
apps/web/services/payments/products.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { getAPIUrl } from '@services/config/config';
|
||||
import { RequestBodyWithAuthHeader, getResponseMetadata } from '@services/utils/ts/requests';
|
||||
|
||||
export async function getProducts(orgId: number, access_token: string) {
|
||||
const result = await fetch(
|
||||
`${getAPIUrl()}payments/${orgId}/products`,
|
||||
RequestBodyWithAuthHeader('GET', null, null, access_token)
|
||||
);
|
||||
const res = await getResponseMetadata(result);
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function createProduct(orgId: number, data: any, access_token: string) {
|
||||
const result = await fetch(
|
||||
`${getAPIUrl()}payments/${orgId}/products`,
|
||||
RequestBodyWithAuthHeader('POST', data, null, access_token)
|
||||
);
|
||||
const res = await getResponseMetadata(result);
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function updateProduct(orgId: number, productId: string, data: any, access_token: string) {
|
||||
const result = await fetch(
|
||||
`${getAPIUrl()}payments/${orgId}/products/${productId}`,
|
||||
RequestBodyWithAuthHeader('PUT', data, null, access_token)
|
||||
);
|
||||
const res = await getResponseMetadata(result);
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function deleteProduct(orgId: number, productId: string, access_token: string) {
|
||||
const result = await fetch(
|
||||
`${getAPIUrl()}payments/${orgId}/products/${productId}`,
|
||||
RequestBodyWithAuthHeader('DELETE', null, null, access_token)
|
||||
);
|
||||
const res = await getResponseMetadata(result);
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function getProductDetails(orgId: number, productId: string, access_token: string) {
|
||||
const result = await fetch(
|
||||
`${getAPIUrl()}payments/${orgId}/products/${productId}`,
|
||||
RequestBodyWithAuthHeader('GET', null, null, access_token)
|
||||
);
|
||||
const res = await getResponseMetadata(result);
|
||||
return res;
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue