Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
8 changes: 5 additions & 3 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';
import Teams from './pages/settings/Teams';
import TeamDetails from './pages/settings/TeamDetails';
import ServiceAccounts from './pages/settings/ServiceAccounts';
Expand All @@ -42,14 +43,15 @@ function Router() {
<Route path="security" element={<Security />} />
<Route path="projects" element={<Projects />} />
<Route path="projects/:projectId" element={<ProjectDetails />} />
<Route path="tokens" element={<Tokens />} />
<Route path="teams" element={<Teams />} />
<Route path="teams/:teamId" element={<TeamDetails />} />
<Route path="service-accounts" element={<ServiceAccounts />} />
<Route path="service-accounts/:serviceAccountId" element={<ServiceAccountDetails />} />
</Route>
</Route >
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</Routes >
</BrowserRouter >
);
}

Expand Down
1 change: 1 addition & 0 deletions web/apps/client-demo/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const NAV_ITEMS = [
{ label: 'Members', path: 'members' },
{ label: 'Security', path: 'security' },
{ label: 'Projects', path: 'projects' },
{ label: 'Tokens', path: 'tokens' },
{ label: 'Teams', path: 'teams' },
{ label: 'Service Accounts', path: 'service-accounts' }
];
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 { TeamsView, TeamDetailsView } from './views-new/teams';
export {
ServiceAccountsView,
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 { ClipboardEvent, KeyboardEvent, 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: KeyboardEvent) =>
['e', 'E', '+', '-', '.'].includes(e.key) &&
e.preventDefault()
}
onPaste={(e: ClipboardEvent) => {
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 { has, get } from 'lodash';
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';
Loading
Loading