Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions web/apps/client-demo/src/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import Members from './pages/settings/Members';
import Security from './pages/settings/Security';
import Projects from './pages/settings/Projects';
import ProjectDetails from './pages/settings/ProjectDetails';
import Tokens from './pages/settings/Tokens';

function Router() {
return (
Expand All @@ -38,6 +39,7 @@ function Router() {
<Route path="security" element={<Security />} />
<Route path="projects" element={<Projects />} />
<Route path="projects/:projectId" element={<ProjectDetails />} />
<Route path="tokens" element={<Tokens />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
Expand Down
3 changes: 2 additions & 1 deletion web/apps/client-demo/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ const NAV_ITEMS = [
{ label: 'Sessions', path: 'sessions' },
{ label: 'Members', path: 'members' },
{ label: 'Security', path: 'security' },
{ label: 'Projects', path: 'projects' }
{ label: 'Projects', path: 'projects' },
{ label: 'Tokens', path: 'tokens' }
];

export default function Settings() {
Expand Down
5 changes: 5 additions & 0 deletions web/apps/client-demo/src/pages/settings/Tokens.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { TokensView } from '@raystack/frontier/react';

export default function Tokens() {
return <TokensView />;
}
1 change: 1 addition & 0 deletions web/sdk/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export { SessionsView } from './views-new/sessions';
export { MembersView } from './views-new/members';
export { SecurityView } from './views-new/security';
export { ProjectsView, ProjectDetailsView } from './views-new/projects';
export { TokensView } from './views-new/tokens';

export type {
FrontierClientOptions,
Expand Down
201 changes: 201 additions & 0 deletions web/sdk/react/views-new/tokens/components/add-tokens-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { useMemo } from 'react';
import { Button, Dialog, Flex, InputField, Skeleton } from '@raystack/apsara-v1';
import { toastManager } from '@raystack/apsara-v1';
import { yupResolver } from '@hookform/resolvers/yup';
import { useMutation, useQuery } from '@connectrpc/connect-query';
import { useForm } from 'react-hook-form';
import * as yup from 'yup';
import { useFrontier } from '~/react/contexts/FrontierContext';
import { DEFAULT_TOKEN_PRODUCT_NAME } from '~/react/utils/constants';
import { CreateCheckoutRequestSchema, FrontierServiceQueries } from '@raystack/proton/frontier';
import { create } from '@bufbuild/protobuf';
import qs from 'query-string';

type DialogHandle = ReturnType<typeof Dialog.createHandle>;

export interface AddTokensDialogProps {
handle: DialogHandle;
}

export function AddTokensDialog({ handle }: AddTokensDialogProps) {
const { config, activeOrganization } = useFrontier();

const tokenProductId =
config?.billing?.tokenProductId || DEFAULT_TOKEN_PRODUCT_NAME;

const { data: product, isLoading } = useQuery(
FrontierServiceQueries.getProduct,
{ id: tokenProductId },
{
enabled: !!tokenProductId,
select: data => data?.product
}
);

const { productDescription, minQuantity, maxQuantity } = useMemo(() => {
let productDescription = '';
let minQuantity = 1;
let maxQuantity = 1000000;
if (product) {
const productPrice = product?.prices?.[0];
const price = Number(productPrice?.amount || '100') / 100;
const currency = productPrice?.currency || 'USD';
productDescription = `1 token = ${currency} ${price}`;
}
const behaviorConfig = product?.behaviorConfig;
if (behaviorConfig?.minQuantity) {
minQuantity = Number(behaviorConfig?.minQuantity);
}
if (behaviorConfig?.maxQuantity) {
maxQuantity = Number(behaviorConfig?.maxQuantity);
}
return { productDescription, minQuantity, maxQuantity };
}, [product]);

const tokensSchema = yup
.object({
tokens: yup
.number()
.required('Please enter valid number')
.min(minQuantity, `Minimum ${minQuantity} token is required`)
.max(maxQuantity, `Maximum ${maxQuantity} tokens are allowed`)
.typeError('Please enter valid number of tokens')
})
Comment thread
rohanchkrabrty marked this conversation as resolved.
.required();

type FormData = yup.InferType<typeof tokensSchema>;

const {
register,
handleSubmit,
formState: { errors, isSubmitting },
setValue
} = useForm({
resolver: yupResolver(tokensSchema)
});

const { mutateAsync: createCheckout, isPending: isCreatingCheckout } =
useMutation(FrontierServiceQueries.createCheckout, {
onSuccess: data => {
const checkoutUrl = data?.checkoutSession?.checkoutUrl;
if (checkoutUrl) window.location.href = checkoutUrl;
},
onError: (error: Error) => {
toastManager.add({
title: 'Something went wrong',
description: error?.message,
type: 'error'
});
}
});

const onSubmit = async (data: FormData) => {
try {
if (!activeOrganization?.id) return;
const query = qs.stringify(
{
details: btoa(
qs.stringify({
organization_id: activeOrganization?.id,
type: 'tokens'
})
),
checkout_id: '{{.CheckoutID}}'
},
{ encode: false }
);
Comment thread
rohanchkrabrty marked this conversation as resolved.
const cancelUrl = `${config?.billing?.cancelUrl}?${query}`;
const successUrl = `${config?.billing?.successUrl}?${query}`;

await createCheckout(
create(CreateCheckoutRequestSchema, {
orgId: activeOrganization?.id || '',
cancelUrl: cancelUrl,
successUrl: successUrl,
productBody: {
product: tokenProductId,
quantity: BigInt(data.tokens)
}
})
);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : 'Unknown error';
toastManager.add({
title: 'Something went wrong',
description: message,
type: 'error'
});
}
};

const isFormSubmitting = isSubmitting || isCreatingCheckout;

return (
<Dialog handle={handle}>
<Dialog.Content width={400} showCloseButton={false}>
<form onSubmit={handleSubmit(onSubmit)}>
<Dialog.Header>
<Dialog.Title>Add tokens</Dialog.Title>
</Dialog.Header>
<Dialog.Body>
<Flex direction="column" gap={5}>
{isLoading ? (
<Skeleton height="60px" width="100%" />
) : (
<InputField
label="Add tokens"
size="large"
type="number"
error={errors.tokens && String(errors.tokens.message)}
{...register('tokens', { valueAsNumber: true })}
placeholder="Enter no. of tokens"
helperText={productDescription}
onKeyDown={(e: React.KeyboardEvent) =>

Check failure on line 153 in web/sdk/react/views-new/tokens/components/add-tokens-dialog.tsx

View workflow job for this annotation

GitHub Actions / JS SDK Lint

'React' is not defined
['e', 'E', '+', '-', '.'].includes(e.key) &&
e.preventDefault()
}
onPaste={(e: React.ClipboardEvent) => {

Check failure on line 157 in web/sdk/react/views-new/tokens/components/add-tokens-dialog.tsx

View workflow job for this annotation

GitHub Actions / JS SDK Lint

'React' is not defined
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
const pastedText = e.clipboardData.getData('text/plain');
const parsedValue = parseInt(pastedText);
e.preventDefault();
if (
!isNaN(parsedValue) &&
parsedValue >= minQuantity &&
parsedValue <= maxQuantity
) {
setValue('tokens', parsedValue, { shouldDirty: true });
}
}}
data-test-id="frontier-sdk-add-tokens-input"
/>
)}
</Flex>
</Dialog.Body>
<Dialog.Footer>
<Flex gap={4} justify="end">
<Button
variant="outline"
color="neutral"
onClick={() => handle.close()}
data-test-id="frontier-sdk-add-tokens-cancel-btn"
>
Cancel
</Button>
<Button
type="submit"
variant="solid"
color="accent"
loading={isFormSubmitting}
disabled={!!errors.tokens || isFormSubmitting || isLoading}
loaderText="Adding..."
data-test-id="frontier-sdk-add-tokens-btn"
>
Continue
</Button>
</Flex>
</Dialog.Footer>
</form>
</Dialog.Content>
</Dialog>
);
}
134 changes: 134 additions & 0 deletions web/sdk/react/views-new/tokens/components/columns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { Avatar, Flex, Text, getAvatarColor } from '@raystack/apsara-v1';
import type { DataTableColumnDef } from '@raystack/apsara-v1';
import type { BillingTransaction } from '@raystack/proton/frontier';
import * as _ from 'lodash';
Comment thread
rohanchkrabrty marked this conversation as resolved.
Outdated
import { getInitials } from '~/utils';
import {
isNullTimestamp,
type TimeStamp,
timestampToDate
} from '~/utils/timestamp';
import dayjs from 'dayjs';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';

dayjs.extend(isSameOrBefore);
dayjs.extend(isSameOrAfter);

interface GetColumnsOptions {
dateFormat: string;
}

const TxnEventSourceMap: Record<string, string> = {
'system.starter': 'Starter tokens',
'system.buy': 'Recharge',
'system.awarded': 'Complimentary tokens',
'system.revert': 'Refund'
};

const eventFilterOptions = Object.entries(TxnEventSourceMap).map(
([value, label]) => ({ value, label })
);

export function getColumns({
dateFormat
}: GetColumnsOptions): DataTableColumnDef<BillingTransaction, unknown>[] {
return [
{
header: 'Date',
accessorKey: 'createdAt',
enableColumnFilter: true,
filterType: 'date',
styles: { cell: { flex: '0 0 140px' }, header: { flex: '0 0 140px' } },
filterFn: (row, columnId, filterValue) => {
const ts = row.getValue(columnId) as unknown as TimeStamp;
if (isNullTimestamp(ts)) return false;
const rowDate = dayjs(timestampToDate(ts));
const filterDate = dayjs(filterValue.date);
if (!rowDate.isValid() || !filterDate.isValid()) return false;
const op = filterValue.operator || 'eq';
switch (op) {
case 'eq': return rowDate.isSame(filterDate, 'day');
case 'neq': return !rowDate.isSame(filterDate, 'day');
case 'lt': return rowDate.isBefore(filterDate, 'day');
case 'lte': return rowDate.isSameOrBefore(filterDate, 'day');
case 'gt': return rowDate.isAfter(filterDate, 'day');
case 'gte': return rowDate.isSameOrAfter(filterDate, 'day');
default: return true;
}
},
cell: ({ getValue }) => {
const value = getValue() as TimeStamp;
const date = isNullTimestamp(value)
? '-'
: dayjs(timestampToDate(value)).format(dateFormat);
return (
<Text size="regular" variant="secondary">
{date}
</Text>
);
}
},
{
header: 'Tokens',
accessorKey: 'amount',
styles: { cell: { flex: '0 0 200px' }, header: { flex: '0 0 200px' } },
cell: ({ row, getValue }) => {
const value = getValue() as bigint;
const prefix = row?.original?.type === 'credit' ? '+' : '-';
return (
<Text size="regular" variant="secondary">
{prefix}
{Number(value)}
</Text>
Comment thread
rohanchkrabrty marked this conversation as resolved.
);
}
},
{
header: 'Event',
accessorKey: 'source',
enableColumnFilter: true,
filterType: 'multiselect',
filterOptions: eventFilterOptions,
cell: ({ row, getValue }) => {
const value = getValue() as string;
const eventName = (
_.has(TxnEventSourceMap, value)
? _.get(TxnEventSourceMap, value)
: row?.original?.description
) as string;
return (
<Text
size="regular"
variant="secondary"
style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block' }}
>
{eventName || '-'}
</Text>
);
}
},
{
header: 'Member',
accessorKey: 'userId',
cell: ({ row, getValue }) => {
const userTitle =
row?.original?.user?.title || row?.original?.user?.email || '-';
const avatarSrc = row?.original?.user?.avatar;
const color = getAvatarColor(getValue() as string);
return (
<Flex direction="row" gap={4} align="center">
<Avatar
src={avatarSrc}
fallback={getInitials(userTitle)}
size={3}
radius="full"
color={color}
/>
<Text size="regular">{userTitle}</Text>
</Flex>
);
}
}
];
}
1 change: 1 addition & 0 deletions web/sdk/react/views-new/tokens/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { TokensView } from './tokens-view';
19 changes: 19 additions & 0 deletions web/sdk/react/views-new/tokens/tokens-view.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.balancePanel {
padding: var(--rs-space-5);
border-radius: var(--rs-radius-2);
border: 0.5px solid var(--rs-color-border-base-secondary);
}

.coinIcon {
height: 48px;
width: 48px;
}

.callout {
width: 100%;
box-sizing: border-box;
}

.tableRoot {
border: none;
}
Loading
Loading