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
34 changes: 29 additions & 5 deletions src/features/clusters/upsert/ClusterBilling.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Button } from '@/components/ui/button';
import { ResourcesPerInstance } from '@/features/clusters/upsert/components/ResourcesPerInstance';
import { PaymentMethodsDisplay } from '@/features/organization/billing/paymentMethod/PaymentMethodsDisplay';
import { getOrganizationQueryOptions } from '@/features/organization/queries/getOrganizationQuery';
import { PaymentMethodStatus } from '@/integrations/stripe/paymentMethodStatus';
import { SchemaPlan } from '@/lib/api.gen';
import { useQuery } from '@tanstack/react-query';
import { ArrowLeftIcon, ArrowRightIcon } from 'lucide-react';
import { useState } from 'react';
Expand All @@ -13,6 +15,8 @@ interface ClusterBillingProps {
readonly onSaveStateForBillingRedirect: (redirecting: boolean) => void;
readonly onSubmit?: () => void;
readonly organizationId: string;
readonly selectedAutoRenew: boolean;
readonly selectedPlan: SchemaPlan | undefined;
}

export function ClusterBilling({
Expand All @@ -22,6 +26,8 @@ export function ClusterBilling({
onSaveStateForBillingRedirect,
onSubmit,
organizationId,
selectedAutoRenew,
selectedPlan,
}: ClusterBillingProps) {
const { data: organization } = useQuery(getOrganizationQueryOptions(organizationId));
const billing = organization?.billing;
Expand Down Expand Up @@ -54,7 +60,7 @@ export function ClusterBilling({
this page.
</li>
<li>Your account representative can work with you to sort out more precise details, and to help
accomplish your objectives with this new
accomplish your objectives with this
cluster. <a href="https://www.harpersystems.dev/contact" target="_blank" className="underline">Contact
us</a>, we are here to help.
</li>
Expand All @@ -77,14 +83,32 @@ export function ClusterBilling({

return (<>
<ul className="list-disc ml-6 mb-6">
<li>You will be billed for this cluster today, and will receive a license for the
block of usage you've requested.
<li>You will be billed for this cluster today, and will receive a license for the block of usage you've
requested.
</li>
<li>When that block is used up, or 3 months elapse, you will be automatically
renewed.
{clusterId && (
<li>If you scale up, you'll be charged for the additional blocks you've purchased
now${selectedAutoRenew ? ', and your next auto renewal will be for all purchased blocks' : ''}.
</li>)}
{clusterId && (<li>If you remove a region, that usage block will not be used (because it is specific to that
region).</li>)}
<li>{selectedAutoRenew
? 'When that block is used up, or 3 months elapse, you will be automatically renewed.'
: 'When that block is used up, or 3 months elapse, you will NOT be automatically renewed.'}
</li>
<li>While refunds are not available, we’d be happy to assist you with... swag, depending on availability.
</li>
<li>We would love to work with you to sort out more precise details, and to help accomplish your objectives
with this
cluster. <a href="https://www.harpersystems.dev/contact" target="_blank" className="underline">Contact
us</a>, we are here to help.
</li>
</ul>

{selectedPlan?.planLimits && selectedPlan.resourcesPerInstance && (
<ResourcesPerInstance planLimits={selectedPlan.planLimits} resourcesPerInstance={selectedPlan.resourcesPerInstance} />
)}

<p className="text-muted-foreground text-sm mb-6">Payment method:</p>

<PaymentMethodsDisplay onSaveStateForBillingRedirect={onSaveStateForBillingRedirect} onReplacingPaymentMethod={setReplacingPaymentMethod} />
Expand Down
41 changes: 29 additions & 12 deletions src/features/clusters/upsert/ClusterDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { UpsertClusterSchema } from '@/features/clusters/upsert/upsertClusterSch
import { SchemaPlan, SchemaRegion } from '@/lib/api.gen';
import { ArrowRight } from 'lucide-react';
import { Suspense, useEffect, useMemo } from 'react';
import { UseFormReturn } from 'react-hook-form';
import { UseFormReturn, useFormState } from 'react-hook-form';
import { z } from 'zod';

interface ClusterDetailsProps {
Expand Down Expand Up @@ -45,14 +45,11 @@ export function ClusterDetails({
selectedPlan,
totalPrice,
}: ClusterDetailsProps) {
const { isDirty, isValid } = useFormState();
const availablePerformanceDescriptions = useMemo(() =>
Object.keys(deploymentToPerformanceToPlan[selectedDeployment] || {}), [deploymentToPerformanceToPlan, selectedDeployment]);
const availableDeploymentTypes = useMemo(() =>
Object.keys(deploymentToPerformanceToPlan).sort((a, b) => {
const aPrice = Object.values(deploymentToPerformanceToPlan[a])[0].priceUsd ?? 0;
const bPrice = Object.values(deploymentToPerformanceToPlan[b])[0].priceUsd ?? 0;
return aPrice - bPrice;
}), [deploymentToPerformanceToPlan]);
Object.keys(deploymentToPerformanceToPlan).sort(), [deploymentToPerformanceToPlan]);

useEffect(function autoSelectFirstAvailablePerformanceDescription() {
if (availablePerformanceDescriptions?.length && !availablePerformanceDescriptions.includes(selectedPerformance)) {
Expand All @@ -61,15 +58,15 @@ export function ClusterDetails({
}
}, [selectedDeployment, selectedPerformance, availablePerformanceDescriptions, form]);

const isSelfManaged = selectedDeployment === 'Manage Your Own Installation/Configuration';
const isSelfManaged = selectedDeployment === 'Self-Hosted';

return (<>
<div className="grid grid-cols-3 gap-6 text-white md:grid-cols-6 overflow-auto max-h-[calc(100vh-theme(spacing.52))]">
<FormField
control={form.control}
name="systemName"
render={({ field }) => (
<FormItem className="col-span-3 md:col-span-6">
<FormItem className="col-span-3">
<FormLabel className="pb-1">Harper System Name</FormLabel>
<FormControl>
<Input
Expand All @@ -84,6 +81,26 @@ export function ClusterDetails({
)}
/>

<FormField
control={form.control}
name="autoRenew"
render={({ field }) => (
<FormItem className="col-span-3">
<FormLabel className="pb-1">Auto Renew</FormLabel>
<FormControl>
<Input
{...field}
value={field.value as unknown as string}
checked={field.value === true}
type="checkbox"
disabled={!!clusterId}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<FormField
control={form.control}
name="deploymentDescription"
Expand Down Expand Up @@ -127,7 +144,7 @@ export function ClusterDetails({
name="performanceDescription"
render={({ field }) => (
<FormItem className="col-span-3">
<FormLabel className="pb-1">Performance &amp; Usage</FormLabel>
<FormLabel className="pb-1">{selectedDeployment.startsWith('Self') ? 'Support' : 'Performance'} &amp; Usage</FormLabel>

<Suspense fallback={<TextLoadingSkeleton />}>
<FormControl>
Expand Down Expand Up @@ -225,12 +242,12 @@ export function ClusterDetails({
/>)
}

{selectedPlan?.resourcesPerInstance && (
<ResourcesPerInstance resourcesPerInstance={selectedPlan.resourcesPerInstance} />
{selectedPlan?.planLimits && selectedPlan.resourcesPerInstance && (
<ResourcesPerInstance planLimits={selectedPlan.planLimits} resourcesPerInstance={selectedPlan.resourcesPerInstance} />
)}
</div>
<DialogFooter className="mt-3">
<Button type="submit" variant="submit" className="rounded-full" disabled={isPending}>
<Button type="submit" variant="submit" className="rounded-full" disabled={isPending || !isDirty || !isValid}>
{totalPrice > 0 ? 'Confirm Payment Details' : clusterId ? 'Edit Cluster' : 'Create New Cluster'}
<ArrowRight />
</Button>
Expand Down
25 changes: 14 additions & 11 deletions src/features/clusters/upsert/ClusterForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function ClusterForm({
const refineZod = useCallback((data: z.infer<typeof UpsertClusterSchema>, ctx: z.RefinementCtx) => {
const names = new Set();
const selectedPlan = deploymentToPerformanceToPlan?.[data.deploymentDescription]?.[data.performanceDescription];
const isSelfManaged = data.deploymentDescription === 'Manage Your Own Installation/Configuration';
const isSelfManaged = data.deploymentDescription === 'Self-Hosted';
if (isSelfManaged) {
for (let i = 0; i < data.instances.length; i++) {
const fqdn = calculateInstanceFQDN(data.instances[i]);
Expand Down Expand Up @@ -130,6 +130,7 @@ export function ClusterForm({
const selectedPerformance = form.watch('performanceDescription');
const selectedRegionPlans = form.watch('regionPlans');
const selectedInstances = form.watch('instances');
const selectedAutoRenew = form.watch('autoRenew');

useEffect(() => {
setLimitRegionParameters({
Expand All @@ -140,7 +141,7 @@ export function ClusterForm({

useEffect(function syncInstancesAndRegionsWithSelfManagedSelection() {
const values = form.getValues();
const isSelfManaged = selectedDeployment === 'Manage Your Own Installation/Configuration';
const isSelfManaged = selectedDeployment === 'Self-Hosted';
if (!selectedDeployment) {
return;
}
Expand Down Expand Up @@ -204,7 +205,7 @@ export function ClusterForm({
}, [selectedPlan, selectedRegionPlans, form, regionNameToLatencyToRegion, regionLocations]);

useEffect(function syncRegionSelectionsWithPossibleRegions() {
const isSelfManaged = selectedDeployment === 'Manage Your Own Installation/Configuration';
const isSelfManaged = selectedDeployment === 'Self-Hosted';
if (!isSelfManaged && Object.keys(regionNameToLatencyToRegion).length && selectedRegionPlans.length) {
for (let i = 0; i < selectedRegionPlans.length; i++) {
const regionPlan = selectedRegionPlans[i];
Expand All @@ -217,7 +218,7 @@ export function ClusterForm({

const totalPrice = !selectedPlan?.priceUsd
? 0
: selectedDeployment === 'Manage Your Own Installation/Configuration'
: selectedDeployment === 'Self-Hosted'
? selectedInstances.length * selectedPlan.priceUsd
: selectedRegionPlans.reduce((total, region) => {
const regionPlan = regionNameToLatencyToRegion?.[region.regionName!]?.[region.latencyDescription!];
Expand All @@ -243,24 +244,24 @@ export function ClusterForm({
const plans: SchemaRegionPlan[] = [];
const plan = deploymentToPerformanceToPlan[formData.deploymentDescription][formData.performanceDescription];

const isSelfManaged = formData.deploymentDescription === 'Manage Your Own Installation/Configuration';
const isSelfManaged = formData.deploymentDescription === 'Self-Hosted';
if (isSelfManaged) {
for (const instance of formData.instances) {
plans.push({
planId: plan.id,
autoRenew: true,
operationsApiSecure: instance.secure === 'true',
autoRenew: formData.autoRenew,
instanceFqdn: instance.fqdn,
operationsApiPort: instance.port || defaultOperationsApiPort,
operationsApiSecure: instance.secure === 'true',
planId: plan.id,
});
}
} else {
for (const regionPlan of formData.regionPlans) {
const region = regionNameToLatencyToRegion[regionPlan.regionName][regionPlan.latencyDescription];
plans.push({
autoRenew: formData.autoRenew,
planId: plan.id,
regionId: region.id,
autoRenew: true,
});
}
}
Expand All @@ -276,7 +277,7 @@ export function ClusterForm({
name: formData.systemName,
abbreviatedName: isSelfManaged ? undefined : (formData.abbreviatedName || calculatedNames.suggestedAbbreviatedName),
fqdn: isSelfManaged && formData.fqdn || undefined,
autoRenew: true,
autoRenew: formData.autoRenew,
regionPlans: plans,
}, { onSuccess: onClusterCreatedCallback });
}
Expand Down Expand Up @@ -334,12 +335,14 @@ export function ClusterForm({
details:</p>

<ClusterBilling
clusterId={clusterId}
isPending={isCreatePending || isEditPending}
onGoBackToDetails={onGoBackToDetails}
onSaveStateForBillingRedirect={onSaveStateForBillingRedirect}
onSubmit={submitCreateCluster}
organizationId={organizationId}
clusterId={clusterId}
selectedAutoRenew={selectedAutoRenew}
selectedPlan={selectedPlan}
/>
</>)
}
Expand Down
21 changes: 12 additions & 9 deletions src/features/clusters/upsert/ClusterRegions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { RegionFormInputs } from '@/features/clusters/upsert/components/RegionFo
import { UpsertClusterSchema } from '@/features/clusters/upsert/upsertClusterSchema';
import { SchemaPlan, SchemaRegion } from '@/lib/api.gen';
import { PlusIcon } from 'lucide-react';
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { useFieldArray, UseFormReturn } from 'react-hook-form';
import { z } from 'zod';

Expand All @@ -27,17 +27,20 @@ export function ClusterRegions({
name: 'regionPlans',
});

const onAddARegionClick = useCallback(() => {
const nextAvailableRegionToAdd = useMemo(() => {
const selectedRegionNames = selectedRegionPlans.map(region => regionNameToLatencyToRegion?.[region.regionName!]?.[region.latencyDescription!]?.region);
const firstNewRegionLocation = regionLocations?.find(r => !selectedRegionNames.includes(r.region));
if (firstNewRegionLocation) {
return regionLocations?.find(r => !selectedRegionNames.includes(r.region));
}, [regionLocations, regionNameToLatencyToRegion, selectedRegionPlans]);

const onAddARegionClick = useCallback(() => {
if (nextAvailableRegionToAdd) {
regionPlansFieldArray.append({
regionName: firstNewRegionLocation.region,
latencyDescription: firstNewRegionLocation.latencyDescription,
regionName: nextAvailableRegionToAdd.region,
latencyDescription: nextAvailableRegionToAdd.latencyDescription,
});
void form.trigger();
}
}, [regionPlansFieldArray, form, regionLocations, regionNameToLatencyToRegion, selectedRegionPlans]);
}, [form, nextAvailableRegionToAdd, regionPlansFieldArray]);

return (<>
{regionPlansFieldArray.fields.map((field, index) => (
Expand All @@ -52,7 +55,7 @@ export function ClusterRegions({
/>
))}

<div className="md:col-span-6 col-span-3">
{nextAvailableRegionToAdd && (<div className="md:col-span-6 col-span-3">
<Button
type="button"
variant="positiveOutline"
Expand All @@ -62,6 +65,6 @@ export function ClusterRegions({
<PlusIcon />
Add Additional Region Usage
</Button>
</div>
</div>)}
</>);
}
9 changes: 5 additions & 4 deletions src/features/clusters/upsert/components/RegionFormInputs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '@/components/ui/select';
import { UpsertClusterSchema } from '@/features/clusters/upsert/upsertClusterSchema';
import { SchemaPlan, SchemaRegion } from '@/lib/api.gen';
import { sortByNumberPrefix } from '@/lib/arrays/sort/byNumberPrefix';
import { TrashIcon } from 'lucide-react';
import { useCallback, useEffect, useMemo } from 'react';
import { Control, UseFieldArrayReturn, UseFormReturn } from 'react-hook-form';
Expand All @@ -36,25 +37,25 @@ export function RegionFormInputs({
regionNameToLatencyToRegion,
}: RegionFormInputsProps) {
const availableRegionNames = useMemo(() =>
Object.keys(regionNameToLatencyToRegion), [regionNameToLatencyToRegion]);
Object.keys(regionNameToLatencyToRegion).sort(), [regionNameToLatencyToRegion]);
const isDedicated = form.watch('deploymentDescription')?.startsWith('Dedicated');
const selectedRegionName = form.watch(`regionPlans.${index}.regionName`);
const selectedLatencyDescription = form.watch(`regionPlans.${index}.latencyDescription`);
const availableLatencyDescriptions = useMemo(() =>
Object.keys(regionNameToLatencyToRegion[selectedRegionName] || {}), [regionNameToLatencyToRegion, selectedRegionName]);
Object.keys(regionNameToLatencyToRegion[selectedRegionName] || {}).sort(sortByNumberPrefix).reverse(), [regionNameToLatencyToRegion, selectedRegionName]);

useEffect(function autoPickLatencyDescription() {
if (selectedRegionName && availableLatencyDescriptions?.length && !availableLatencyDescriptions?.includes(selectedLatencyDescription)) {
const oldValue = selectedLatencyDescription?.split(' ')[0].toLowerCase();
const newValue = availableLatencyDescriptions.find(description => !oldValue ? true : description.split(' ')[0].toLowerCase() === oldValue) || availableLatencyDescriptions[0];
form.setValue(`regionPlans.${index}.latencyDescription`, newValue);
form.trigger();
void form.trigger();
}
}, [availableLatencyDescriptions, form, index, selectedLatencyDescription, selectedRegionName]);

const onRemoveClicked = useCallback(() => {
fieldArray?.remove(index);
form.trigger();
void form.trigger();
}, [fieldArray, form, index]);

return (
Expand Down
Loading