This commit is contained in:
John Yeo
2025-10-11 15:02:31 +01:00
parent a9671bc4df
commit 9f17bb88b2
112 changed files with 2527 additions and 9898 deletions

35
AGENTS.md Normal file
View File

@@ -0,0 +1,35 @@
# Basic rules
- Never run a "dev" or "build" command, chances are I'm already running it in the background. Just ask me to check for updates or whatever you need
- Never ever ever write a "TO DO" comment. If you've been told to do something, DO IT. Don't stop halfway. Never give up and just leave a "to do" comment and say - "haha heres working code :)" - that is unacceptible. Always finish your task, no matter how many iterations you need to perform.
- DO NOT alter .gitignore
- JS Doc comments should be SHORT and SWEET. Don't need examples unless ABSOLUTELY necessary
# Linting and Codebase rules
- You can access the biome linter by running `npx biome check <folder or file path>`. Always specify a folder path, as the codebase is quite large and you will get out of scope errors that you are not burdened to correct. If you would like to let biome automatically fix as much as it can, use `npx biome check --write <folder or file path>`
- Note, biome does not perform typechecking. In which case you need to, you may run `tsc --noEmit --skipLibCheck <folder or file path>`
- This codebase uses Bun as its preferred package manager and Node runtime.
- Always prefer foo({ bar }) over foo(bar) method signatures - no matter if we are using only one argument or not, object as param are always better, as in the future when wanting to change the order of parameters, or add new ones - its easier.
- When creating "hooks" folders, don't nest them under "components"
- Functions (unless there's a very good reason) should always take in objects as arguments. Object params are named and easy to understand.
## Bad example
/ root
-> components
|-> hooks
## Good example
/ root
-> components
-> hooks
# Figma MCP guidance
- When you are using the Figma MCP server, you **must** follow our design system. Below is an example implementation of CVA with out design system
## File Naming
DON'T name files one word (like index.ts, model.ts, etc.). Give proper indication in the filename to which resource it's targeting. For example, a utility file for organizations should be named orgUtils.ts. This is because it's easier to search for files like this. That being said, the filename shouldn't be overly long (less than three words is ideal)

View File

@@ -1,3 +0,0 @@
<!-- Talk about context -->
<!-- -->

914
bun.lock

File diff suppressed because it is too large Load Diff

43
example/.gitignore vendored
View File

@@ -1,43 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# Remove .env.local from gitignore
!.env.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

View File

@@ -1,21 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -1,21 +0,0 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"@typescript-eslint/no-explicit-any": "off",
},
},
];
export default eslintConfig;

View File

@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

7021
example/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +0,0 @@
{
"name": "nextjs-autumn-template",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"autumn-js": "^0.0.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.483.0",
"next": "15.2.3",
"next-themes": "^0.4.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sonner": "^2.0.1",
"tailwind-merge": "^3.0.2",
"tw-animate-css": "^1.2.4"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"eslint": "^9",
"eslint-config-next": "15.2.3",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -1,5 +0,0 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

View File

@@ -1,16 +0,0 @@
import Application from "@/components/application";
import CustomerDetailsExample from "@/components/billing";
import Intro from "@/components/introduction";
export default function Home() {
return (
<div className="min-h-screen w-full p-6 flex flex-col gap-8 max-w-7xl mx-auto">
<Intro />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Application />
<CustomerDetailsExample />
</div>
</div>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,143 +0,0 @@
@import "tailwindcss";
@import "tw-animate-css";
button {
padding: 5px 10px;
background-color: #8838ff;
color: white;
cursor: pointer;
border-radius: 5px;
font-weight: 500;
}
button:hover {
background-color: #413652;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
/* Custom font sizes */
--text-xs: 12px; /* Changed from default 11px to 12px */
--text-sm: 13px;
--text-md: 15px;
--text-lg: 17px;
--text-xl: 20px;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,45 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
import { AutumnProvider } from "autumn-js/next";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Next.js Autumn App",
description: "Starter Next.js app with Autumn",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<AutumnProvider
customerId="123"
customerData={{
name: "ayush",
email: "ayush@example.com",
}}
>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Toaster position="top-right" />
{children}
</body>
</AutumnProvider>
</html>
);
}

View File

@@ -1,21 +0,0 @@
"use client";
import { useAutumn } from "autumn-js/next";
export default function Home() {
const { customer, attach } = useAutumn();
return (
<div className="h-screen w-full flex flex-col gap-4 items-center justify-center">
<div className="text-2xl font-bold">{customer?.name}</div>
<button
onClick={async () => {
await attach({
productId: "pro-example",
});
}}
>
Upgrade
</button>
</div>
);
}

View File

@@ -1,28 +0,0 @@
"use server";
import { Autumn } from "@/sdk/autumn";
export const getReferralCode = async (customerId: string) => {
const autumn = new Autumn();
const referralCode = await autumn.referrals.createCode({
customerId,
referralId: "referral",
});
return referralCode;
};
export const redeemReferralCode = async ({
customerId,
referralCode,
}: {
customerId: string;
referralCode: string;
}) => {
const autumn = new Autumn();
const redemption = await autumn.referrals.redeem({
customerId,
code: referralCode,
});
return redemption;
};

View File

@@ -1,169 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { getReferralCode, redeemReferralCode } from "./functions";
import { toast } from "sonner";
import { attachProduct, getCustomer } from "../autumn-functions";
import { Input } from "@/components/ui/input";
const useReferralCode = (referrerId: string) => {
const [referralCode, setReferralCode] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
const fetchReferralCode = async () => {
setLoading(true);
try {
const { code } = await getReferralCode(referrerId);
setReferralCode(code);
} catch (error) {
toast.error(`Error fetching referral code: ${error}`);
}
setLoading(false);
};
fetchReferralCode();
}, []);
return { referralCode, isLoading: loading };
};
const useCustomer = (customerId: string) => {
const [customer, setCustomer] = useState<any>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const fetchCustomer = async () => {
setIsLoading(true);
const customerData = await getCustomer(customerId);
setCustomer(customerData);
setIsLoading(false);
};
const refresh = async () => {
const customerData = await getCustomer(customerId);
setCustomer(customerData);
};
useEffect(() => {
fetchCustomer();
}, []);
return { ...customer, isLoading, refresh };
};
export default function ReferralsPage() {
const { referralCode, isLoading } = useReferralCode("ayush");
const { entitlements } = useCustomer("ayush");
const referrerId = "ayush";
const referee1Id = "john";
const [referral1Code, setReferral1Code] = useState<string>("");
return (
<div className="min-h-screen bg-zinc-50 p-8">
<div className="max-w-6xl mx-auto">
<h1 className="text-2xl font-mono mb-8 text-zinc-800">
Referral Program
</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Referrer Section */}
<div className="bg-white border border-zinc-200 rounded-lg p-6 shadow-sm">
<div className="space-y-4">
<div className="flex items-center space-x-2 pb-2 border-b border-zinc-100">
<span className="text-xs font-mono text-zinc-500">
USER ID:
</span>
<span className="font-mono text-sm text-zinc-800 bg-zinc-100 px-2 py-0.5 rounded">
{referrerId}
</span>
</div>
<h2 className="text-lg font-mono text-zinc-700">
Your Referral Code
</h2>
<div className="bg-zinc-50 p-4 rounded-md border border-zinc-200">
<p className="font-mono text-lg tracking-wide text-zinc-800">
{isLoading
? "Loading..."
: referralCode || "No code available"}
</p>
</div>
{entitlements && (
<div className="mt-6 space-y-3">
<h3 className="text-sm font-mono text-zinc-600">
Your Features
</h3>
{entitlements.map((entitlement: any, index: number) => (
<div
key={index}
className="flex items-center justify-between py-2 px-3 bg-zinc-50 rounded-md"
>
<span className="font-mono text-sm text-zinc-700">
{entitlement.feature_id}
</span>
<span className="font-mono text-sm bg-zinc-200 px-2 py-1 rounded">
{entitlement.balance}
</span>
</div>
))}
</div>
)}
</div>
</div>
{/* Referee Section */}
<div className="bg-white border border-zinc-200 rounded-lg p-6 shadow-sm">
<div className="space-y-4">
<div className="flex items-center space-x-2 pb-2 border-b border-zinc-100">
<span className="text-xs font-mono text-zinc-500">
USER ID:
</span>
<span className="font-mono text-sm text-zinc-800 bg-zinc-100 px-2 py-0.5 rounded">
{referee1Id}
</span>
</div>
<h2 className="text-lg font-mono text-zinc-700">Redeem Code</h2>
<div className="space-y-2">
<p className="text-sm font-mono text-zinc-600">
Enter referral code to get started
</p>
<Input
value={referral1Code}
onChange={(e) => setReferral1Code(e.target.value)}
placeholder="Enter code"
className="font-mono text-base"
/>
<button
onClick={async () => {
try {
await redeemReferralCode({
customerId: referee1Id,
referralCode: referral1Code,
});
const { checkout_url } = await attachProduct({
customerId: referee1Id,
productId: "pro",
});
if (checkout_url) {
window.open(checkout_url, "_blank");
} else {
toast.error("Something went wrong");
}
} catch (error) {
console.log("Failed to redeem code", error);
toast.error("Failed to redeem code");
}
}}
className="w-full mt-4 bg-zinc-800 hover:bg-zinc-700 text-white font-mono py-2 px-4 rounded-md transition-colors"
>
Redeem & Purchase Pro
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,66 +0,0 @@
import { useAutumn } from "autumn-js/next";
import { MessageSquare } from "lucide-react";
import { toast } from "sonner";
export default function Application() {
const { entitled, sendEvent, refetch } = useAutumn();
const sendMessageClicked = async (featureId: string) => {
const { allowed } = await entitled({
featureId,
});
if (!allowed) {
toast.error(`You're out of ${featureId}!`);
return;
}
await sendEvent({
featureId,
});
await refetch();
toast.success(`${featureId} used!`);
};
return (
<div className="border rounded-lg bg-white overflow-hidden flex flex-col">
<div className="border-b p-6">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h2 className="text-lg font-semibold">Feature Access Example</h2>
<p className="text-sm text-muted-foreground">
Test how our feature access and event sending works
</p>
</div>
<div className="h-8 w-8 rounded-lg bg-purple-50 flex items-center justify-center">
<MessageSquare className="h-4 w-4 text-purple-600" />
</div>
</div>
</div>
<div className="p-6 flex-1">
<div className="space-y-2">
<div className="text-sm font-medium">How it works:</div>
<ol className="text-sm space-y-2 text-muted-foreground list-decimal list-inside">
<li>First calls /entitled to check message allowance</li>
<li>If allowed, calls /events to record the message</li>
<li>Updates remaining message count</li>
</ol>
</div>
</div>
<div className="p-6 pt-0 flex gap-2">
<button
className="w-full bg-purple-600 hover:bg-purple-700 transition-colors"
onClick={async () => {
await sendMessageClicked("chat-messages");
}}
>
Use Chat Message
</button>
</div>
</div>
);
}

View File

@@ -1,103 +0,0 @@
"use client";
import { CreditCard } from "lucide-react";
import { toast } from "sonner";
import { useAutumn } from "autumn-js/next";
export default function CustomerDetailsExample() {
const { customer, attach, openBillingPortal } = useAutumn();
const productId = "pro-example";
const getEntitlement = (featureId: string) => {
return customer?.features.find(
(entitlement: any) => entitlement.feature_id === featureId,
);
};
const upgradeClicked = async () => {
try {
await attach({
productId,
});
} catch (error: any) {
toast.error(`${error.message}`);
}
};
const manageBillingClicked = async () => {
try {
await openBillingPortal();
} catch (error: any) {
toast.error(`${error.message}`);
}
};
const messageCredits = getEntitlement("chat-messages");
const hasPro =
customer?.products?.length && customer?.products[0].id === productId;
return (
<div className="border rounded-lg bg-white overflow-hidden flex flex-col">
<div className="border-b p-6">
<div className="flex items-start justify-between">
<div className="space-y-1">
<h2 className="text-lg font-semibold">Customer Details</h2>
<p className="text-sm text-muted-foreground">
Current subscription and feature access
</p>
</div>
<div className="h-8 w-8 rounded-lg bg-stone-50 flex items-center justify-center">
<CreditCard className="h-4 w-4 text-stone-600" />
</div>
</div>
</div>
<div className="p-6 flex-1">
<div className="space-y-3">
<div className="flex items-center justify-between py-2 border-b">
<span className="text-sm font-medium">Customer ID</span>
<span className="text-sm font-mono bg-stone-50 px-2 py-1 rounded">
{customer?.id}
</span>
</div>
<div className="flex items-center justify-between py-2 border-b">
<span className="text-sm font-medium">Chat Messages Remaining</span>
<span className="text-sm font-mono bg-stone-50 px-2 py-1 rounded">
{messageCredits?.unlimited
? "Unlimited"
: messageCredits?.balance || 0}
</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="text-sm font-medium">Current Plan</span>
<span className="text-sm font-medium">
{hasPro ? (
<span className="bg-purple-50 text-purple-700 px-2 py-1 rounded-full">
Pro
</span>
) : (
<span className="bg-stone-50 text-stone-600 px-2 py-1 rounded-full">
Free
</span>
)}
</span>
</div>
</div>
</div>
<div className="flex gap-2 p-6 pt-0">
{!hasPro && (
<button className="w-full" onClick={upgradeClicked}>
<div className="w-full pt-0">Upgrade to Pro</div>
</button>
)}
<div className="w-full pt-0">
<button className="w-full" onClick={manageBillingClicked}>
Manage Billing
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,52 +0,0 @@
import Link from "next/link";
export default function Intro() {
return (
<div className="space-y-8">
{/* Header Section */}
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">
Welcome to the Next.js Autumn template
</h1>
<p className="text-muted-foreground">
Get started with Autumn by setting up your account and exploring the
core features.
</p>
</div>
{/* Setup Requirements */}
<div className="p-6 border rounded-lg bg-stone-50 space-y-4">
<h2 className="font-semibold">Before you get started</h2>
<ul className="space-y-3 text-sm">
<li className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-stone-300" />
<span>
Create your Autumn secret key{" "}
<Link
href="https://app.useautumn.com/sandbox/dev"
className="text-stone-700 underline underline-offset-4 hover:text-stone-900"
target="_blank"
>
here
</Link>{" "}
and add it to the .env.local file
</span>
</li>
<li className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-stone-300" />
<span>
Connect your Stripe account{" "}
<Link
href="https://app.useautumn.com/sandbox/integrations/stripe"
className="text-stone-700 underline underline-offset-4 hover:text-stone-900"
target="_blank"
>
here
</Link>
</span>
</li>
</ul>
</div>
</div>
);
}

View File

@@ -1,21 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -1,25 +0,0 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View File

@@ -1,21 +0,0 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
undefined,
);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

View File

@@ -1,6 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,28 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"noImplicitAny": false,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -9,7 +9,7 @@
"type": "module",
"scripts": {
"dev": "node scripts/start-dev.js",
"dev:simple": "concurrently \"cd server && bun dev\" \"cd server && bun workers:dev\" \"cd vite && bun dev\" \"cd shared && bun dev\"",
"dev:simple": "concurrently \"cd shared && bun dev:watch\" \"cd server && bun dev\" \"cd server && bun workers:dev\" \"cd vite && bun dev\"",
"vite:build": "bun -F @autumn/shared build && bun -F @autumn/vite build:bun",
"vite:start": "bun -F @autumn/vite start:bun",
"shared": "bun -F @autumn/shared build",
@@ -29,9 +29,7 @@
"docker:up:ci": "docker compose -f docker-compose.ci.yml up --build",
"build:all": "pnpm -F shared build && pnpm -F server prod:build && pnpm -F vite build",
"vite:build:bun": "bun -F @autumn/shared build && bun -F @autumn/vite build:bun",
"vite:start:bun": "bun -F @autumn/shared build && bun -F @autumn/vite start:bun",
"dev:bun": "concurrently \"cd server && bun run dev\" \"cd vite && bun run dev\" \"bun -F @autumn/shared dev:bun\"",
"build:all:bun": "bun run -F @autumn/shared build:bun && bun run -F @autumn/server prod:build:bun && bun run -F @autumn/vite build:bun"
"vite:start:bun": "bun -F @autumn/shared build && bun -F @autumn/vite start:bun"
},
"dependencies": {
"@wooorm/starry-night": "^3.8.0",

View File

@@ -6,17 +6,41 @@ async function startDev() {
// Detect and set ports
const { vitePort, serverPort } = await detectAndSetPorts();
console.log("\n🚀 Starting development servers...\n");
// Step 1: Build shared package first (initial build)
console.log("\n📦 Building shared package...\n");
const buildShared = spawn("bun", ["run", "build"], {
cwd: "shared",
stdio: "inherit",
shell: true,
});
// Start concurrently with the detected ports
await new Promise((resolve, reject) => {
buildShared.on("close", (code) => {
if (code !== 0) {
reject(new Error(`Shared package build failed with code ${code}`));
} else {
resolve();
}
});
buildShared.on("error", reject);
});
console.log("\n✅ Shared package built successfully!\n");
console.log("🚀 Starting development servers in watch mode...\n");
// Step 2: Start server, workers, and vite first (they'll use the built shared package)
const concurrentlyCmd = spawn(
"bunx",
[
"concurrently",
`"cd shared && bun run dev"`,
"-n",
"server,workers,vite,shared",
"-c",
"green,yellow,blue,cyan",
`"cd server && SERVER_PORT=${serverPort} bun dev"`,
`"cd server && bun workers:dev"`,
`"cd vite && VITE_PORT=${vitePort} bun dev"`,
`"cd shared && bun run dev:watch"`,
],
{
stdio: "inherit",

14
server/example.ts Normal file
View File

@@ -0,0 +1,14 @@
// @ts-nocheck
await autumn.customerProducts.create({
customer_id: "",
product_id: "pro",
})
await autumn.customerLicenses.create({
customer_id: "",
product_id: "pro",
quantity: 5,
license_to: ["ent_1"]
})

View File

@@ -7,8 +7,8 @@
"scripts": {
"email": "email dev -p 3001",
"start": "bun src/index.ts",
"dev": "cross-env NODE_ENV=development bunx nodemon -w ../shared/dist -w src --ext js,ts --exec bun src/index.ts --ignore scripts --ignore tests",
"workers:dev": "cross-env NODE_ENV=development bunx nodemon -w ../shared/dist -w src/workers.ts -w src/queue --ext js,ts --exec bun src/workers.ts --ignore scripts --ignore tests",
"dev": "cross-env NODE_ENV=development bunx nodemon -w ../shared/dist -w src --ext js,ts --ignore '../shared/dist/**/*.d.ts' --ignore scripts --ignore tests --exec bun src/index.ts",
"workers:dev": "cross-env NODE_ENV=development bunx nodemon -w ../shared/dist -w src/workers.ts -w src/queue --ext js,ts --ignore '../shared/dist/**/*.d.ts' --ignore scripts --ignore tests --exec bun src/workers.ts",
"workers": "bun src/workers.ts",
"cron": "bun src/cron.ts",
"check": "bun src/check.ts",

View File

@@ -238,7 +238,7 @@ export const handleInvoicePaid = async ({
cusProducts.map((cp) => `${cp.product.name} - ${cp.product.id}`),
);
if (cusProducts.length == 0) {
if (cusProducts.length === 0) {
cusProducts = activeCusProducts;
}
} catch (error) {

View File

@@ -1,13 +1,11 @@
import { AppEnv, InvoiceStatus } from "@autumn/shared";
import Stripe from "stripe";
import { getFullStripeInvoice, invoiceToSubId } from "../stripeInvoiceUtils.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { DrizzleCli } from "@/db/initDrizzle.js";
import { getMetadataFromCheckoutSession } from "@/internal/metadata/metadataUtils.js";
import { MetadataService } from "@/internal/metadata/MetadataService.js";
import { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { type AppEnv, InvoiceStatus } from "@autumn/shared";
import type Stripe from "stripe";
import type { DrizzleCli } from "@/db/initDrizzle.js";
import { CusService } from "@/internal/customers/CusService.js";
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService.js";
import type { AttachParams } from "@/internal/customers/cusProducts/AttachParams.js";
import { InvoiceService } from "@/internal/invoices/InvoiceService.js";
import { MetadataService } from "@/internal/metadata/MetadataService.js";
import { getFullStripeInvoice, invoiceToSubId } from "../stripeInvoiceUtils.js";
const handleInvoiceCheckoutVoided = async ({
db,
@@ -51,9 +49,9 @@ const handleInvoiceCheckoutVoided = async ({
if (!subId) return;
const cusSubIds = fullCus.customer_products
.map((cp) => cp.subscription_ids || [])
.flat();
const cusSubIds = fullCus.customer_products.flatMap(
(cp) => cp.subscription_ids || [],
);
const subIdMatch = cusSubIds.includes(subId);

View File

@@ -198,7 +198,7 @@ const handleFeatureUsageTypeChanged = async ({
console.log(
`Feature usage type changed to ${newUsageType}, updating entitlements and prices`,
);
if (newUsageType == FeatureUsageType.Continuous) {
if (newUsageType === FeatureUsageType.Continuous) {
const batchEntUpdate = [];
for (const entitlement of entitlements) {
batchEntUpdate.push(
@@ -230,7 +230,7 @@ const handleFeatureUsageTypeChanged = async ({
config: {
...priceConfig,
should_prorate:
newUsageType == FeatureUsageType.Continuous ? false : true, // if continuous, don't prorate -> get usage_in_arrear type...
newUsageType === FeatureUsageType.Continuous ? false : true, // if continuous, don't prorate -> get usage_in_arrear type...
stripe_price_id: null,
},
},
@@ -263,7 +263,7 @@ export const handleUpdateFeature = async (
// 1. Get feature by ID
const features = await FeatureService.getFromReq(req);
const feature = features.find((f) => f.id == featureId);
const feature = features.find((f) => f.id === featureId);
if (!feature) {
throw new RecaseError({
@@ -305,9 +305,9 @@ export const handleUpdateFeature = async (
const isChangingId = notNullish(data.id) && feature.id !== data.id;
const isChangingUsageType =
feature.type != FeatureType.Boolean &&
data.type != FeatureType.Boolean &&
feature.config?.usage_type != data.config?.usage_type;
feature.type !== FeatureType.Boolean &&
data.type !== FeatureType.Boolean &&
feature.config?.usage_type !== data.config?.usage_type;
const isChangingName = feature.name !== data.name;
@@ -366,9 +366,9 @@ export const handleUpdateFeature = async (
const newConfig =
data.config !== undefined
? feature.type == FeatureType.CreditSystem
? feature.type === FeatureType.CreditSystem
? validateCreditSystem(data.config)
: feature.type == FeatureType.Metered
: feature.type === FeatureType.Metered
? validateMeteredConfig(data.config)
: data.config
: feature.config;

View File

@@ -1,7 +1,7 @@
import { CreateFeatureSchema, ErrCode, FeatureType } from "@autumn/shared";
import express, { type Router } from "express";
import { handleDeleteFeature } from "@/internal/features/handlers/handleDeleteFeature.js";
import { handleUpdateFeature } from "@/internal/features/handlers/handleUpdateFeature.js";
// import { handleUpdateFeature } from "@/internal/features/handlers/handleUpdateFeature.js";
import RecaseError, { formatZodError } from "@/utils/errorUtils.js";
import { generateId } from "@/utils/genUtils.js";
import { FeatureService } from "./FeatureService.js";
@@ -12,6 +12,7 @@ import {
} from "./featureUtils.js";
import { handleCreateFeature } from "./handlers/handleCreateFeature.js";
import { handleGetFeatureDeletionInfo } from "./handlers/handleGetFeatureDeletionInfo.js";
import { handleUpdateFeature } from "./handlers/handleUpdateFeature.js";
export const internalFeatureRouter: Router = express.Router();

View File

@@ -1,24 +1,21 @@
import { generateId } from "better-auth";
import { NextFunction, Router } from "express";
import {
AppEnv,
member,
Organization,
type Organization,
organizations,
StripeConfig,
type StripeConfig,
user as userTable,
} from "@autumn/shared";
import { ExtendedRequest } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { and, eq } from "drizzle-orm";
import { connectStripe } from "../orgs/handlers/handleConnectStripe.js";
import { z } from "zod";
import { createKey } from "../dev/api-keys/apiKeyUtils.js";
import { afterOrgCreated } from "@/utils/authUtils/afterOrgCreated.js";
import { Autumn } from "autumn-js";
import { generateId } from "better-auth";
import { and, eq } from "drizzle-orm";
import { type NextFunction, Router } from "express";
import { z } from "zod";
import { afterOrgCreated } from "@/utils/authUtils/afterOrgCreated.js";
import type { ExtendedRequest } from "@/utils/models/Request.js";
import { routeHandler } from "@/utils/routerUtils.js";
import { createKey } from "../dev/api-keys/apiKeyUtils.js";
import { connectStripe } from "../orgs/handlers/handleConnectStripe.js";
import { shouldReconnectStripe } from "../orgs/orgUtils.js";
@@ -32,7 +29,7 @@ const platformAuthMiddleware = async (
if (!process.env.AUTUMN_SECRET_KEY) next();
try {
let autumn = new Autumn();
const autumn = new Autumn();
const { data, error } = await autumn.check({
customer_id: req.org.id,
feature_id: "platform",
@@ -84,7 +81,8 @@ platformRouter.post("/exchange", (req: any, res: any) =>
res,
action: "exchange",
handler: async (req: ExtendedRequest, res: any) => {
let { organization, email, stripe_test_key, stripe_live_key } = req.body;
const { organization, email, stripe_test_key, stripe_live_key } =
req.body;
const { db, logger } = req;
@@ -160,13 +158,13 @@ platformRouter.post("/exchange", (req: any, res: any) =>
),
);
let membership = data.length > 0 ? data[0] : null;
const membership = data.length > 0 ? data[0] : null;
if (!membership) {
logger.info(`Connected to Stripe`);
// 2. Create org
let orgId = generateId();
const orgId = generateId();
[org] = (await db
.insert(organizations)
@@ -215,7 +213,7 @@ platformRouter.post("/exchange", (req: any, res: any) =>
});
if (reconnectStripe) {
let {
const {
test_api_key,
test_webhook_secret,
defaultCurrency: newDefaultCurrency,
@@ -256,7 +254,7 @@ platformRouter.post("/exchange", (req: any, res: any) =>
if (reconnectStripe) {
console.log("Reconnecting stripe live");
let {
const {
live_api_key,
live_webhook_secret,
defaultCurrency: newDefaultCurrency,

View File

@@ -223,12 +223,12 @@ export const toFeatureAndPrice = ({
type: PriceType.Usage,
bill_when:
item.usage_model == UsageModel.Prepaid
item.usage_model === UsageModel.Prepaid
? BillWhen.StartOfPeriod
: BillWhen.EndOfPeriod,
billing_units: item.billing_units || 1,
should_prorate: entInterval == EntInterval.Lifetime,
should_prorate: entInterval === EntInterval.Lifetime,
internal_feature_id: internalFeatureId,
feature_id: item.feature_id!,

View File

@@ -1,25 +1,20 @@
import { z } from "zod/v4";
// Create Entity Params (based on CreateEntitySchema from shared/models)
export const CreateEntityParamsSchema = z
.object({
id: z.string().meta({
description: "The ID of the entity",
example: "entity_123",
}),
name: z.string().nullish().meta({
description: "The name of the entity",
example: "Team Alpha",
}),
feature_id: z.string().meta({
description: "The ID of the feature this entity is associated with",
example: "seats",
}),
})
.meta({
id: "CreateEntityParams",
description: "Parameters for creating an entity",
});
export const CreateEntityParamsSchema = z.object({
id: z.string().meta({
description: "The ID of the entity",
example: "entity_123",
}),
name: z.string().nullish().meta({
description: "The name of the entity",
example: "Team Alpha",
}),
feature_id: z.string().meta({
description: "The ID of the feature this entity is associated with",
example: "seats",
}),
});
// Get Entity Query Params
export const GetEntityQuerySchema = z.object({

View File

@@ -0,0 +1,29 @@
import { RecaseError } from "../base/RecaseError.js";
import { FeatureErrorCode } from "../codes/featureErrCodes.js";
/**
* Product not found error
*/
export class FeatureAlreadyExistsError extends RecaseError {
constructor(opts: { productId: string; version?: string }) {
super({
message: `Product ${opts.productId} ${opts.version ? ` (version ${opts.version})` : ""} not found`,
code: FeatureErrorCode.FeatureAlreadyExists,
statusCode: 404,
});
this.name = "ProductNotFoundError";
}
}
/**
* Feature not found error
*/
export class FeatureNotFoundError extends RecaseError {
constructor(opts: { featureId: string }) {
super({
message: `Feature ${opts.featureId} not found`,
code: FeatureErrorCode.FeatureNotFound,
statusCode: 404,
});
}
}

View File

@@ -0,0 +1,7 @@
export const FeatureErrorCode = {
FeatureNotFound: "feature_not_found",
FeatureAlreadyExists: "feature_already_exists",
} as const;
export type FeatureErrorCode =
(typeof FeatureErrorCode)[keyof typeof FeatureErrorCode];

View File

@@ -2,6 +2,7 @@ import { z } from "zod/v4";
export enum ApiFeatureType {
Static = "static", // legacy (will deprecate)
Boolean = "boolean",
SingleUsage = "single_use",
ContinuousUse = "continuous_use",

View File

@@ -1,6 +1,29 @@
import { ApiFeatureSchema } from "@api/features/apiFeature.js";
import type { z } from "zod/v4";
import { z } from "zod/v4";
import { ApiFeatureType } from "./apiFeature.js";
export const UpdateFeatureParamsSchema = ApiFeatureSchema.partial();
export const UpdateFeatureParamsSchema = z.object({
id: z.string().optional(),
name: z.string().nullish(),
type: z.enum(ApiFeatureType).optional(),
archived: z.boolean().optional(),
credit_schema: z
.array(
z.object({
metered_feature_id: z.string(),
credit_cost: z.number(),
}),
)
.nullish(),
display: z
.object({
singular: z.string(),
plural: z.string(),
})
.optional(),
event_names: z.array(z.string()).optional(),
});
export type UpdateFeatureParams = z.infer<typeof UpdateFeatureParamsSchema>;

View File

@@ -26,7 +26,8 @@ export * from "./customers/previousVersions/apiCustomerV2.js";
export * from "./entities/apiEntity.js";
// NOTE: entitiesOpenApi.js is NOT exported here - it's only imported by openapi.ts for spec generation
export * from "./entities/entityOpModels.js";
export * from "./errors/classes/featureErrClasses.js";
export * from "./errors/codes/featureErrCodes.js";
// Features
export * from "./features/apiFeature.js";
export * from "./features/featureOpModels.js";

View File

@@ -16,6 +16,7 @@
"api": "bun api/openapi.ts",
"build": "tsc --emitDeclarationOnly --outDir dist && bun build ./index.ts --outdir dist --format esm --target bun --external zod",
"dev": "bunx nodemon --ext ts --ignore dist --exec \"tsc --emitDeclarationOnly --outDir dist && bun build ./index.ts --outdir dist --format esm --target bun --external zod\"",
"dev:watch": "bun scripts/watch.js",
"db:push": "bun db:generate && bun db:migrate",
"db:generate": "cross-env NODE_OPTIONS=\"--import tsx\" bunx drizzle-kit generate --config drizzle.config.ts",
"db:migrate": "cross-env NODE_OPTIONS=\"--import tsx\" bunx drizzle-kit migrate --config drizzle.config.ts",
@@ -28,6 +29,7 @@
"drizzle-kit": "^0.31.1",
"drizzle-orm": "^0.43.1",
"drizzle-zod": "^0.8.2",
"yaml": "^2.8.1",
"zod-openapi": "^5.4.1"
},
"peerDependencies": {

88
shared/scripts/watch.js Normal file
View File

@@ -0,0 +1,88 @@
import { spawn } from "node:child_process";
import { watch } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = resolve(dirname(fileURLToPath(import.meta.url)), "..");
let isBuilding = false;
let rebuildQueued = false;
function runBuild() {
if (isBuilding) {
rebuildQueued = true;
return;
}
isBuilding = true;
console.log("\n📦 [shared] Rebuilding...");
const build = spawn(
"bun",
[
"build",
"./index.ts",
"--outdir",
"dist",
"--format",
"esm",
"--target",
"bun",
"--external",
"zod",
],
{
cwd: __dirname,
stdio: "inherit",
shell: true,
},
);
// Run tsc in parallel
const tsc = spawn("tsc", ["--emitDeclarationOnly", "--outDir", "dist"], {
cwd: __dirname,
stdio: "inherit",
shell: true,
});
Promise.all([
new Promise((resolve) => build.on("close", resolve)),
new Promise((resolve) => tsc.on("close", resolve)),
]).then(() => {
console.log("✅ [shared] Rebuild complete");
isBuilding = false;
if (rebuildQueued) {
rebuildQueued = false;
setTimeout(() => runBuild(), 100);
}
});
}
// Watch for changes (excluding dist directory)
const watcher = watch(__dirname, { recursive: true }, (eventType, filename) => {
if (
!filename ||
filename.includes("dist/") ||
filename.includes("node_modules/") ||
filename.includes("scripts/") ||
!filename.endsWith(".ts")
) {
return;
}
console.log(`\n📝 [shared] Changed: ${filename}`);
runBuild();
});
console.log("👀 [shared] Watching for changes...");
process.on("SIGINT", () => {
watcher.close();
process.exit(0);
});
process.on("SIGTERM", () => {
watcher.close();
process.exit(0);
});

View File

@@ -2,6 +2,11 @@ import { ApiFeatureSchema } from "@api/features/apiFeature.js";
import type { CreditSchemaItem } from "../models/featureModels/featureConfig/creditConfig.js";
import { FeatureType } from "../models/featureModels/featureEnums.js";
import type { Feature } from "../models/featureModels/featureModels.js";
// import {
// constructBooleanFeature,
// constructCreditSystem,
// constructMeteredFeature,
// } from "./featureUtils/constructFeatureUtils.js";
export const toApiFeature = ({ feature }: { feature: Feature }) => {
// return FeatureResponseSchema.parse(feature);

View File

@@ -0,0 +1,105 @@
import { type ApiFeature, ApiFeatureType } from "@api/features/apiFeature.js";
import type { UpdateFeatureParams } from "@api/features/updateFeatureParams.js";
import {
FeatureType,
type FeatureUsageType,
} from "@models/featureModels/featureEnums.js";
import type { Feature } from "@models/featureModels/featureModels.js";
import { AppEnv } from "@models/genModels/genEnums.js";
export const apiFeatureToDbFeature = ({
apiFeature,
originalFeature,
}: {
apiFeature: ApiFeature | UpdateFeatureParams;
originalFeature?: Feature;
}) => {
// Replace body...
let featureType = apiFeature.type as unknown as FeatureType;
let usageType: FeatureUsageType | undefined;
if (
apiFeature.type === ApiFeatureType.SingleUsage ||
apiFeature.type === ApiFeatureType.ContinuousUse
) {
featureType = FeatureType.Metered;
usageType = apiFeature.type as unknown as FeatureUsageType;
}
const newConfig =
featureType === FeatureType.Boolean
? undefined
: originalFeature?.config || {};
if (usageType) {
newConfig.usage_type = usageType;
}
if (apiFeature.credit_schema) {
newConfig.schema = apiFeature.credit_schema.map((credit) => ({
metered_feature_id: credit.metered_feature_id,
credit_amount: credit.credit_cost,
}));
}
return {
internal_id: originalFeature?.internal_id ?? "",
org_id: originalFeature?.org_id ?? "",
created_at: originalFeature?.created_at ?? Date.now(),
env: originalFeature?.env ?? AppEnv.Sandbox,
id: apiFeature.id ?? originalFeature?.id ?? "",
name: apiFeature.name ?? originalFeature?.name ?? "",
type: featureType,
config: newConfig,
archived: apiFeature.archived ?? originalFeature?.archived ?? false,
} satisfies Feature;
};
// export const fromApiFeature = ({
// apiFeature,
// orgId,
// env,
// }: {
// apiFeature: ApiFeature;
// orgId: string;
// env: AppEnv;
// }) => {
// const isMetered =
// apiFeature.type === ApiFeatureType.SingleUsage ||
// apiFeature.type === ApiFeatureType.ContinuousUse;
// const featureType: FeatureType = isMetered
// ? FeatureType.Metered
// : (apiFeature.type as unknown as FeatureType);
// if (isMetered) {
// return constructMeteredFeature({
// featureId: apiFeature.id,
// name: apiFeature.name || "",
// usageType: apiFeature.type as unknown as FeatureUsageType,
// orgId,
// env,
// });
// }
// if (featureType === FeatureType.CreditSystem) {
// if (!apiFeature.credit_schema || apiFeature.credit_schema.length === 0) {
// throw new Error("Credit system schema is required");
// }
// return constructCreditSystem({
// featureId: apiFeature.id,
// name: apiFeature.name || "",
// orgId,
// env,
// schema: apiFeature.credit_schema!,
// });
// }
// return constructBooleanFeature({
// featureId: apiFeature.id,
// name: apiFeature.name || "",
// orgId,
// env,
// });
// };

View File

@@ -0,0 +1,152 @@
// import {
// AggregateType,
// type AppEnv,
// type Feature,
// FeatureType,
// FeatureUsageType,
// generateId,
// keyToTitle,
// } from "@autumn/shared";
// export const constructFeature = ({
// id,
// name,
// orgId,
// type,
// env,
// config,
// display,
// }: {
// id: string;
// name: string;
// orgId: string;
// type: FeatureType;
// env: AppEnv;
// config: any;
// display: any;
// }) => {
// const newFeature: Feature = {
// internal_id: generateId("fe"),
// id,
// name,
// org_id: orgId,
// env,
// created_at: Date.now(),
// type,
// config,
// display,
// archived: false,
// };
// return newFeature;
// };
// export const constructBooleanFeature = ({
// featureId,
// orgId,
// env,
// name,
// }: {
// featureId: string;
// orgId: string;
// env: AppEnv;
// name?: string;
// }) => {
// const newFeature: Feature = {
// internal_id: generateId("fe"),
// org_id: orgId,
// env,
// created_at: Date.now(),
// id: featureId,
// name: name || keyToTitle(featureId),
// type: FeatureType.Boolean,
// config: null,
// archived: false,
// };
// return newFeature;
// };
// export const constructMeteredFeature = ({
// featureId,
// name,
// orgId,
// env,
// usageType,
// }: {
// featureId: string;
// name?: string;
// orgId: string;
// env: AppEnv;
// usageType: FeatureUsageType;
// }) => {
// const newFeature: Feature = {
// internal_id: generateId("fe"),
// org_id: orgId,
// env,
// created_at: Date.now(),
// id: featureId,
// name: name || keyToTitle(featureId),
// type: FeatureType.Metered,
// config: {
// filters: [
// {
// property: "event_name",
// operator: "eq",
// value: [],
// },
// ],
// aggregate: {
// type: AggregateType.Sum,
// property: "value",
// },
// usage_type: usageType,
// },
// archived: false,
// };
// return newFeature;
// };
// export const constructCreditSystem = ({
// featureId,
// name,
// orgId,
// env,
// schema,
// }: {
// featureId: string;
// name?: string;
// orgId: string;
// env: AppEnv;
// schema: {
// metered_feature_id: string;
// credit_cost: number;
// }[];
// }) => {
// const config = {
// schema: schema.map((item) => ({
// feature_amount: 1,
// metered_feature_id: item.metered_feature_id,
// credit_amount: item.credit_cost,
// })),
// usage_type: FeatureUsageType.Single,
// };
// const newFeature: Feature = {
// internal_id: generateId("fe"),
// org_id: orgId,
// env,
// created_at: Date.now(),
// id: featureId,
// name: name || keyToTitle(featureId),
// type: FeatureType.CreditSystem,
// config,
// archived: false,
// };
// return newFeature;
// };

View File

@@ -14,6 +14,7 @@ export * from "./cusProductUtils/cusProductConstants.js";
export * from "./cusProductUtils/cusProductUtils.js";
export * from "./cusProductUtils/formatCusProductUtils.js";
export * from "./cusProductUtils/productIdToCusProduct.js";
export * from "./featureUtils/apiFeatureToDbFeature.js";
// Feature utils
export * from "./featureUtils.js";
// Product utils

View File

@@ -105,18 +105,48 @@ export const featureItemsAreSame = ({
item1: FeatureItem;
item2: FeatureItem;
}) => {
// Compare config objects (including rollover)
const configsAreSame =
JSON.stringify(item1.config) === JSON.stringify(item2.config);
const checks = {
feature_id: {
condition: item1.feature_id === item2.feature_id,
message: `Feature ID different: ${item1.feature_id} != ${item2.feature_id}`,
},
included_usage: {
condition: item1.included_usage == item2.included_usage,
message: `Included usage different: ${item1.included_usage} != ${item2.included_usage}`,
},
interval: {
condition: item1.interval == item2.interval,
message: `Interval different: ${item1.interval} != ${item2.interval}`,
},
interval_count: {
condition: (item1.interval_count || 1) == (item2.interval_count || 1),
message: `Interval count different: ${item1.interval_count} != ${item2.interval_count}`,
},
entity_feature_id: {
condition: item1.entity_feature_id == item2.entity_feature_id,
message: `Entity feature ID different: ${item1.entity_feature_id} != ${item2.entity_feature_id}`,
},
reset_usage_when_enabled: {
condition:
item1.reset_usage_when_enabled == item2.reset_usage_when_enabled,
message: `Reset usage when enabled different: ${item1.reset_usage_when_enabled} !== ${item2.reset_usage_when_enabled}`,
},
config: {
condition: JSON.stringify(item1.config) === JSON.stringify(item2.config),
message: `Config different: ${JSON.stringify(item1.config)} !== ${JSON.stringify(item2.config)}`,
},
};
const same =
item1.feature_id === item2.feature_id &&
item1.included_usage == item2.included_usage &&
item1.interval == item2.interval &&
(item1.interval_count || 1) == (item2.interval_count || 1) &&
item1.entity_feature_id == item2.entity_feature_id &&
item1.reset_usage_when_enabled == item2.reset_usage_when_enabled &&
configsAreSame;
const same = Object.values(checks).every((d) => d.condition);
if (!same) {
console.log(
"Feature items different:",
Object.values(checks)
.filter((d) => !d.condition)
.map((d) => d.message),
);
}
return same;
};

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/suspicious/noDoubleEquals: comparison functions require double equals */
import type { Feature } from "../../../models/featureModels/featureModels.js";
import type { FullProduct } from "../../../models/productModels/productModels.js";
import {
@@ -52,26 +53,38 @@ export const compareDetails = ({
newProductV2?: ProductV2;
curProductV2?: ProductV2;
}) => {
let detailsSame = true;
const checks = {
is_add_on: {
condition: newProductV2?.is_add_on === curProductV2?.is_add_on,
message: `Is add-on different: ${newProductV2?.is_add_on} !== ${curProductV2?.is_add_on}`,
},
is_default: {
condition: newProductV2?.is_default === curProductV2?.is_default,
message: `Is default different: ${newProductV2?.is_default} !== ${curProductV2?.is_default}`,
},
archived: {
condition: newProductV2?.archived === curProductV2?.archived,
message: `Archived different: ${newProductV2?.archived} !== ${curProductV2?.archived}`,
},
group: {
condition: newProductV2?.group == curProductV2?.group,
message: `Group different: ${newProductV2?.group} !== ${curProductV2?.group}`,
},
name: {
condition: newProductV2?.name == curProductV2?.name,
message: `Name different: ${newProductV2?.name} !== ${curProductV2?.name}`,
},
};
if (newProductV2?.is_add_on !== curProductV2?.is_add_on) {
detailsSame = false;
}
const detailsSame = Object.values(checks).every((d) => d.condition);
if (newProductV2?.is_default !== curProductV2?.is_default) {
detailsSame = false;
}
if (newProductV2?.archived !== curProductV2?.archived) {
detailsSame = false;
}
if (newProductV2?.group !== curProductV2?.group) {
detailsSame = false;
}
if (newProductV2?.name !== curProductV2?.name) {
detailsSame = false;
if (!detailsSame) {
console.log(
"Product details different:",
Object.values(checks)
.filter((d) => !d.condition)
.map((d) => d.message),
);
}
return detailsSame;
@@ -148,15 +161,12 @@ export const productsAreSame = ({
if (items1.length !== items2.length) itemsSame = false;
for (const item of items1) {
// console.log(`Base ${formatItem({ item, features })}`);
// console.log("Base item:", formatItem({ item, features }));
const similarItem = findSimilarItem({
item,
items: items2,
});
// console.log(`Similar ${formatItem({ item, features })}`);
if (!similarItem) {
if (isFeaturePriceItem(item) || isPriceItem(item)) {
pricesChanged = true;

View File

@@ -12,3 +12,17 @@ export const idRegex = /^[a-zA-Z0-9_-]+$/;
export const sumValues = (vals: number[]) => {
return vals.reduce((acc, curr) => acc + curr, 0);
};
export const keyToTitle = (key: string) => {
return key
.replace(/[-_]/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase());
};
// export const generateId = (prefix: string) => {
// if (!prefix) {
// return KSUID.randomSync().string;
// } else {
// return `${prefix}_${KSUID.randomSync().string}`;
// }
// };

View File

@@ -86,7 +86,8 @@
"tailwindcss": "^4.0.13",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"zod": "^4"
"zod": "^4",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.21.0",

View File

@@ -1,13 +1,8 @@
import type { ProductV2 } from "@autumn/shared";
import { PlanTypeBadge } from "./PlanTypeBadge";
interface PlanTypeBadgesProps {
product: {
is_default?: boolean;
is_add_on?: boolean;
free_trial?: {
card_required?: boolean;
};
};
product: ProductV2;
className?: string;
}

View File

@@ -18,10 +18,16 @@ export const ShortcutButton = ({
return "Ctrl";
};
useHotkeys([`meta+${metaShortcut}`], (e) => {
e.preventDefault();
props?.onClick?.(e as unknown as React.MouseEvent<HTMLButtonElement>);
});
useHotkeys(
[`meta+${metaShortcut}`],
(e) => {
e.preventDefault();
props?.onClick?.(e as unknown as React.MouseEvent<HTMLButtonElement>);
},
{
enableOnFormTags: true,
},
);
const keystrokeContainer = (keyStroke: string) => (
<div className="bg-[#B07AFF] text-primary-foreground flex items-center justify-center size-4 rounded-md">
@@ -35,7 +41,9 @@ export const ShortcutButton = ({
{metaShortcut && (
<span className="flex items-center gap-0.5">
{keystrokeContainer(getMetaKey())}
{keystrokeContainer(metaShortcut.toUpperCase())}
{keystrokeContainer(
metaShortcut === "enter" ? "↵" : metaShortcut.toUpperCase(),
)}
</span>
)}
</Button>

View File

@@ -8,43 +8,46 @@ type StringKeys<T> = {
type UseAutoSlugProps<T, S extends StringKeys<T>, U extends StringKeys<T>> = {
state: T;
setState: (updater: T) => void;
setState: (updater: T | ((prev: T) => T)) => void;
sourceKey: S;
targetKey: U;
disableAutoSlug?: boolean;
};
export function useAutoSlug<
T,
S extends StringKeys<T>,
U extends StringKeys<T>,
>({ state, setState, sourceKey, targetKey }: UseAutoSlugProps<T, S, U>) {
const targetManuallyChangedRef = useRef(false);
>({ state, setState, sourceKey, targetKey, disableAutoSlug = false }: UseAutoSlugProps<T, S, U>) {
const targetManuallyChangedRef = useRef(disableAutoSlug);
const setSource = useCallback(
(newSource: string) => {
const updates: T = {
...state,
[sourceKey]: newSource as T[S],
};
setState((prevState: T) => {
const updates: T = {
...prevState,
[sourceKey]: newSource as T[S],
};
if (!targetManuallyChangedRef.current) {
updates[targetKey] = slugify(newSource) as T[U];
}
if (!targetManuallyChangedRef.current && !disableAutoSlug) {
updates[targetKey] = slugify(newSource) as T[U];
}
setState(updates);
return updates;
});
},
[state, setState, sourceKey, targetKey],
[setState, sourceKey, targetKey, disableAutoSlug],
);
const setTarget = useCallback(
(newTarget: string) => {
targetManuallyChangedRef.current = true;
setState({
...state,
setState((prevState: T) => ({
...prevState,
[targetKey]: newTarget as T[U],
});
}));
},
[state, setState, targetKey],
[setState, targetKey],
);
return { setSource, setTarget };

View File

@@ -0,0 +1,31 @@
import type { CreateFeature, Feature } from "@autumn/shared";
import { create } from "zustand";
import { getDefaultFeature } from "@/views/products/features/utils/defaultFeature";
interface FeatureState {
feature: CreateFeature;
baseFeature: Feature | null;
setFeature: (feature: CreateFeature | ((prev: CreateFeature) => CreateFeature)) => void;
setBaseFeature: (feature: Feature | null) => void;
reset: () => void;
}
const initialState = {
feature: getDefaultFeature(),
baseFeature: null as Feature | null,
};
export const useFeatureStore = create<FeatureState>((set) => ({
...initialState,
setFeature: (feature) => {
if (typeof feature === "function") {
// Handle updater function pattern: setFeature(prev => newFeature)
set((state) => ({ feature: feature(state.feature) }));
} else {
// Handle direct value: setFeature(newFeature)
set({ feature });
}
},
setBaseFeature: (baseFeature) => set({ baseFeature }),
reset: () => set(initialState),
}));

View File

@@ -0,0 +1,86 @@
import { type ProductV2, productsAreSame } from "@autumn/shared";
import { useMemo } from "react";
import { create } from "zustand";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { DEFAULT_PRODUCT } from "@/views/products/plan/utils/defaultProduct";
interface ProductState {
// The product being edited (working copy)
product: ProductV2;
// The base/original product (for comparison)
baseProduct: ProductV2 | null;
// Actions
setProduct: (product: ProductV2 | ((prev: ProductV2) => ProductV2)) => void;
setBaseProduct: (product: ProductV2 | null) => void;
reset: () => void;
}
const initialState = {
product: DEFAULT_PRODUCT,
baseProduct: null as ProductV2 | null,
};
export const useProductStore = create<ProductState>((set) => ({
...initialState,
setProduct: (product) => {
if (typeof product === "function") {
// Handle updater function pattern: setProduct(prev => newProduct)
set((state) => ({ product: product(state.product) }));
} else {
// Handle direct value: setProduct(newProduct)
set({ product });
}
},
setBaseProduct: (baseProduct) => set({ baseProduct }),
reset: () => set(initialState),
}));
// Custom hooks for computed values
export const useHasChanges = () => {
const product = useProductStore((s) => s.product);
const baseProduct = useProductStore((s) => s.baseProduct);
const { features = [] } = useFeaturesQuery();
return useMemo(() => {
if (!baseProduct) return false;
const comparison = productsAreSame({
newProductV2: product as unknown as ProductV2,
curProductV2: baseProduct as unknown as ProductV2,
features,
});
return (
!comparison.itemsSame ||
!comparison.detailsSame ||
!comparison.freeTrialsSame
);
}, [product, baseProduct, features]);
};
export const useWillVersion = () => {
const product = useProductStore((s) => s.product);
const baseProduct = useProductStore((s) => s.baseProduct);
const { features = [] } = useFeaturesQuery();
return useMemo(() => {
if (!baseProduct) return false;
const comparison = productsAreSame({
newProductV2: product as unknown as ProductV2,
curProductV2: baseProduct as unknown as ProductV2,
features,
});
return (
!comparison.optionsSame ||
!comparison.itemsSame ||
!comparison.freeTrialsSame
);
}, [product, baseProduct, features]);
};

View File

@@ -0,0 +1,38 @@
import type { ProductV2 } from "@autumn/shared";
import { useEffect, useRef } from "react";
import { useProductStore } from "./useProductStore";
/**
* Syncs product store with backend data (single product query)
*/
export const useProductSync = ({
product,
}: {
product: ProductV2 | undefined;
}) => {
const setBaseProduct = useProductStore((s) => s.setBaseProduct);
const setProduct = useProductStore((s) => s.setProduct);
const hasInitialized = useRef(false);
const lastProductRef = useRef<ProductV2 | null>(null);
useEffect(() => {
if (!product) return;
// Check if this is a new product (ID changed) or if product data changed
const isNewProduct = lastProductRef.current?.id !== product.id;
const isProductUpdated = lastProductRef.current !== product;
if (isNewProduct || isProductUpdated) {
lastProductRef.current = product;
// Always update baseProduct to reflect backend state
setBaseProduct(product);
// Only update product on initial load or when switching products
if (!hasInitialized.current || isNewProduct) {
setProduct(product);
hasInitialized.current = true;
}
}
}, [product, setBaseProduct, setProduct]);
};

View File

@@ -0,0 +1,54 @@
import { create } from "zustand";
// Sheet types that can be displayed
export type SheetType =
| "edit-plan"
| "edit-feature"
| "new-feature"
| "select-feature"
| null;
// Store state interface
interface SheetState {
// Current sheet type being displayed
type: SheetType;
// Item ID being edited (e.g., "item-0", "item-1", product.id, or "new"/"select")
itemId: string | null;
// Actions
setSheet: (params: { type: SheetType; itemId?: string | null }) => void;
closeSheet: () => void;
reset: () => void;
}
// Initial state
const initialState = {
type: null as SheetType,
itemId: null as string | null,
};
export const useSheetStore = create<SheetState>((set) => ({
...initialState,
// Set the sheet type and optional itemId
setSheet: ({ type, itemId = null }) => {
set({ type, itemId });
},
// Close the sheet
closeSheet: () => {
set({ type: null, itemId: null });
},
// Reset to initial state
reset: () => set(initialState),
}));
// Convenience selectors for common patterns
export const useIsSheetOpen = () => useSheetStore((s) => s.type !== null);
export const useIsEditingPlan = () =>
useSheetStore((s) => s.type === "edit-plan");
export const useIsEditingFeature = () =>
useSheetStore((s) => s.type === "edit-feature");
export const useIsCreatingFeature = () =>
useSheetStore((s) => s.type === "new-feature" || s.itemId === "new");

View File

@@ -79,6 +79,7 @@
&:focus,
&[data-state="open"] {
background-color: var(--color-active-primary);
border-color: var(--primary);
}
}

View File

@@ -1,4 +1,3 @@
import type { CreateFeature } from "@autumn/shared";
import { productV2ToBasePrice } from "@autumn/shared";
import { CrosshairSimpleIcon } from "@phosphor-icons/react";
import { PricingTableContainer } from "@/components/autumn/PricingTableContainer";
@@ -7,28 +6,41 @@ import { PlanTypeBadges } from "@/components/v2/badges/PlanTypeBadges";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { Separator } from "@/components/v2/separator";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useFeatureStore } from "@/hooks/stores/useFeatureStore";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { PlanCardToolbar } from "../products/plan/components/PlanCard/PlanCardToolbar";
import { PlanFeatureList } from "../products/plan/components/PlanCard/PlanFeatureList";
import { useProductContext } from "../products/product/ProductContext";
import { DummyFeatureRow } from "./components/DummyFeatureRow";
import { useOnboarding3QueryState } from "./hooks/useOnboarding3QueryState";
import { useOnboardingStore } from "./store/useOnboardingStore";
import { getStepNumber } from "./utils/onboardingUtils";
interface OnboardingPreviewProps {
currentStep: number;
playgroundMode?: "edit" | "preview";
setConnectStripeOpen?: (open: boolean) => void;
feature?: CreateFeature;
}
export const OnboardingPreview = ({
currentStep,
playgroundMode = "edit",
setConnectStripeOpen,
feature,
}: OnboardingPreviewProps) => {
const { product, setSheet, setEditingState } = useProductContext();
// Get step from query state
const { queryStates } = useOnboarding3QueryState();
const step = queryStates.step;
// Get state from Zustand
const playgroundMode = useOnboardingStore((state) => state.playgroundMode);
const feature = useFeatureStore((state) => state.feature);
const setSheet = useSheetStore((state) => state.setSheet);
const handleDeletePlanSuccess = useOnboardingStore(
(s) => s.handleDeletePlanSuccess,
);
const product = useProductStore((s) => s.product);
const { products: allProducts } = useProductsQuery();
const currentStep = getStepNumber(step);
const showBasicInfo = currentStep >= 1;
const showPricing = currentStep >= 1;
const showDummyFeature = currentStep === 2;
@@ -51,8 +63,7 @@ export const OnboardingPreview = ({
}
const handleEdit = () => {
setEditingState({ type: "plan", id: null });
setSheet("edit-plan");
setSheet({ type: "edit-plan" });
};
// Show preview mode for step 4 (Playground) when in preview mode OR step 5
@@ -87,6 +98,7 @@ export const OnboardingPreview = ({
{showToolbar && (
<PlanCardToolbar
onEdit={handleEdit}
onDeleteSuccess={handleDeletePlanSuccess || undefined}
deleteDisabled={allProducts?.length === 1}
deleteTooltip={
allProducts?.length === 1
@@ -98,11 +110,11 @@ export const OnboardingPreview = ({
</div>
</div>
{showBasicInfo && product?.description && (
{/* {showBasicInfo && product?.description && (
<span className="text-sm text-t3 max-w-[80%] line-clamp-2">
{product.description}
</span>
)}
)} */}
{/* {showBasicInfo &&
!(product?.description || product?.name || basePrice?.amount) && (

View File

@@ -1,12 +1,15 @@
import type { FrontendProduct, ProductV2 } from "@autumn/shared";
import { ArrowLeftIcon } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import { IconButton } from "@/components/v2/buttons/IconButton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/v2/tooltips/Tooltip";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useEnv } from "@/utils/envUtils";
import { navigateTo } from "@/utils/genUtils";
import { OnboardingSteps } from "@/views/onboarding3/components/OnboardingSteps";
import { ProductContext } from "@/views/products/product/ProductContext";
@@ -15,46 +18,41 @@ import { SaveChangesBar } from "../products/plan/components/SaveChangesBar";
import ConnectStripeDialog from "./ConnectStripeDialog";
import { OnboardingStepRenderer } from "./components/OnboardingStepRenderer";
import { StepHeader } from "./components/StepHeaders";
import { useInitFeature } from "./hooks/useInitProductAndFeature";
import { useInitFeatureItem } from "./hooks/useInitFeatureItem";
import { useOnboarding3QueryState } from "./hooks/useOnboarding3QueryState";
import { useOnboardingLogic } from "./hooks/useOnboardingLogic";
import { useOnboardingProductSync } from "./hooks/useOnboardingProductSync";
import { OnboardingPreview } from "./OnboardingPreview";
import { getStepNumber, OnboardingStep } from "./utils/onboardingUtils";
import { OnboardingStep } from "./utils/onboardingUtils";
export default function OnboardingContent() {
const [connectStripeOpen, setConnectStripeOpen] = useState(false);
const {
// Data
product,
setProduct,
diff,
baseProduct,
feature,
setFeature,
step,
products,
selectedProductId,
const navigate = useNavigate();
const env = useEnv();
// UI State
sheet,
setSheet,
editingState,
setEditingState,
playgroundMode,
setPlaygroundMode,
isQueryLoading,
isButtonLoading,
// Get query data
const { isLoading: productsLoading } = useProductsQuery();
const { isLoading: featuresLoading } = useFeaturesQuery();
// Handlers
handleNext,
handleBack,
handlePlanSelect,
onCreatePlanSuccess,
handleRefetch,
// Get step from query state
const { queryStates } = useOnboarding3QueryState();
const step = queryStates.step;
// Utils
validateStep,
navigate,
env,
} = useOnboardingLogic();
// Sync product store with products list (like useProductSync but for onboarding)
useOnboardingProductSync();
// Initialize feature data
useInitFeature();
// Initialize feature item for Step 3 (handles refresh scenario)
useInitFeatureItem();
// Initialize onboarding logic and store handlers
useOnboardingLogic();
// Compute loading state
const isQueryLoading = productsLoading || featuresLoading;
if (isQueryLoading) {
return <LoadingScreen />;
@@ -69,20 +67,12 @@ export default function OnboardingContent() {
<ProductContext.Provider
value={{
setShowNewVersionDialog: () => {},
product,
setProduct,
entityFeatureIds: [],
setEntityFeatureIds: () => {},
diff,
sheet,
setSheet,
editingState,
setEditingState,
refetch: handleRefetch,
refetch: async () => {}, // Not needed in onboarding
}}
>
{step === OnboardingStep.Integration ? (
// Full-width centered layout for Integration step
// NOTE: This section kept with original layout as per user request
<div className="relative w-full h-full bg-[#EEEEEE]">
{/* Exit button - takes up space on left */}
<div className="fixed pt-4 pl-4 z-10">
@@ -109,52 +99,23 @@ export default function OnboardingContent() {
{/* Top right: Step header and controls - takes up space on right */}
<div className="fixed pt-4 pr-4 right-0 z-10 flex flex-col gap-2 items-end">
<div className="bg-card border-base border rounded-[12px] shadow-sm p-4">
<StepHeader
step={step}
selectedProductId={selectedProductId}
products={products}
onPlanSelect={handlePlanSelect}
onCreatePlanSuccess={onCreatePlanSuccess}
playgroundMode={playgroundMode}
setPlaygroundMode={setPlaygroundMode}
sheet={sheet}
editingState={editingState}
/>
{/* Components access Zustand directly - no props! */}
<StepHeader />
</div>
<div className="bg-card border-base border rounded-[12px] shadow-sm p-4 w-full">
<OnboardingSteps
totalSteps={5}
currentStep={getStepNumber(step)}
nextText={
step === OnboardingStep.Integration ? "Finish" : "Next"
}
onNext={handleNext}
onBack={handleBack}
backDisabled={false}
nextDisabled={
!validateStep(
step,
product as unknown as ProductV2,
feature,
)
}
isLoading={isButtonLoading}
/>
{/* Components access Zustand directly - no props! */}
<OnboardingSteps />
</div>
</div>
{/* Main content - centered between islands */}
<div className="w-full h-full flex justify-center overflow-y-auto py-4 pl-[200px] pr-[432px]">
<OnboardingStepRenderer
step={step}
feature={feature}
setFeature={setFeature}
playgroundMode={playgroundMode}
/>
{/* Components access Zustand directly - no props! */}
<OnboardingStepRenderer />
</div>
</div>
) : (
// Standard layout for other steps
// Standard layout for other steps - NO PROP DRILLING!
<div className="relative w-full h-full flex bg-[#EEEEEE]">
{/* Exit button */}
<div className="absolute top-4 left-4 z-10">
@@ -179,20 +140,12 @@ export default function OnboardingContent() {
</div>
<div className="w-4/5 flex items-center justify-center relative">
<OnboardingPreview
currentStep={getStepNumber(step)}
playgroundMode={playgroundMode}
setConnectStripeOpen={setConnectStripeOpen}
feature={feature}
/>
{/* Components access Zustand directly - no props! */}
<OnboardingPreview setConnectStripeOpen={setConnectStripeOpen} />
{step === OnboardingStep.Playground && (
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2 z-10">
<SaveChangesBar
isOnboarding={true}
originalProduct={baseProduct as unknown as FrontendProduct}
setOriginalProduct={() => {}} // Controlled by useOnboardingLogic
/>
<SaveChangesBar isOnboarding={true} />
</div>
)}
</div>
@@ -200,44 +153,17 @@ export default function OnboardingContent() {
<div className="h-full flex flex-col p-3 min-w-lg max-w-lg">
<div className="rounded-lg h-full flex flex-col p-1 gap-[0.625rem] overflow-x-hidden">
<div className="bg-card border-base border overflow-x-hidden rounded-[12px] shadow-sm mt-1 p-4 shrink-0">
<StepHeader
step={step}
selectedProductId={selectedProductId}
products={products}
onPlanSelect={handlePlanSelect}
onCreatePlanSuccess={onCreatePlanSuccess}
playgroundMode={playgroundMode}
setPlaygroundMode={setPlaygroundMode}
sheet={sheet}
editingState={editingState}
/>
{/* Components access Zustand directly - no props! */}
<StepHeader />
</div>
<div className="bg-card border-base border overflow-x-hidden rounded-[12px] shadow-sm p-0 flex-1 overflow-y-auto">
<OnboardingStepRenderer
step={step}
feature={feature}
setFeature={setFeature}
playgroundMode={playgroundMode}
/>
{/* Components access Zustand directly - no props! */}
<OnboardingStepRenderer />
</div>
<div className="bg-card border-base border rounded-[12px] shadow-sm flex flex-col p-4 shrink-0">
<div className="flex items-center justify-center">
<OnboardingSteps
totalSteps={5}
currentStep={getStepNumber(step)}
nextText={getStepNumber(step) === 5 ? "Finish" : "Next"}
onNext={handleNext}
onBack={handleBack}
backDisabled={getStepNumber(step) === 1}
nextDisabled={
!validateStep(
step,
product as unknown as ProductV2,
feature,
)
}
isLoading={isButtonLoading}
/>
{/* Components access Zustand directly - no props! */}
<OnboardingSteps />
</div>
</div>
</div>

View File

@@ -1,18 +1,13 @@
import type { CreateFeature } from "@autumn/shared";
import { useFeatureStore } from "@/hooks/stores/useFeatureStore";
import { NewFeatureAdvanced } from "@/views/products/plan/components/new-feature/NewFeatureAdvanced";
import { NewFeatureBehaviour } from "@/views/products/plan/components/new-feature/NewFeatureBehaviour";
import { NewFeatureDetails } from "../../products/plan/components/new-feature/NewFeatureDetails";
import { NewFeatureType } from "../../products/plan/components/new-feature/NewFeatureType";
interface FeatureCreationStepProps {
feature: CreateFeature;
setFeature: (feature: CreateFeature) => void;
}
export const FeatureCreationStep = () => {
const feature = useFeatureStore((s) => s.feature);
const setFeature = useFeatureStore((s) => s.setFeature);
export const FeatureCreationStep = ({
feature,
setFeature,
}: FeatureCreationStepProps) => {
return (
<div className="flex flex-col h-full">
<NewFeatureDetails feature={feature} setFeature={setFeature} />

View File

@@ -1,13 +1,17 @@
import type { CreateFeature, ProductItem, ProductV2 } from "@autumn/shared";
import type { ProductItem, ProductV2 } from "@autumn/shared";
import { productV2ToFeatureItems } from "@autumn/shared";
import { useState } from "react";
import { useFeatureStore } from "@/hooks/stores/useFeatureStore";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { getItemId } from "@/utils/product/productItemUtils";
import { useProductContext } from "@/views/products/product/ProductContext";
import { ProductItemContext } from "@/views/products/product/product-item/ProductItemContext";
import { EditPlanFeatureSheet } from "../../products/plan/components/EditPlanFeatureSheet/EditPlanFeatureSheet";
import { EditPlanSheet } from "../../products/plan/components/EditPlanSheet";
import { NewFeatureSheet } from "../../products/plan/components/new-feature/NewFeatureSheet";
import { SelectFeatureSheet } from "../../products/plan/components/SelectFeatureSheet";
import { useOnboarding3QueryState } from "../hooks/useOnboarding3QueryState";
import { useOnboardingStore } from "../store/useOnboardingStore";
import { OnboardingStep } from "../utils/onboardingUtils";
import { FeatureConfigurationStep } from "./FeatureConfigurationStep";
import { FeatureCreationStep } from "./FeatureCreationStep";
@@ -16,20 +20,19 @@ import { PlanDetailsStep } from "./PlanDetailsStep";
import { AvailableFeatures } from "./playground-step/AvailableFeatures";
import { QuickStartCodeGroup } from "./playground-step/QuickStartCodeGroup";
interface OnboardingStepRendererProps {
step: OnboardingStep;
feature: CreateFeature;
setFeature: (feature: CreateFeature) => void;
playgroundMode?: "edit" | "preview";
}
export const OnboardingStepRenderer = () => {
// Get step from query state
const { queryStates } = useOnboarding3QueryState();
const step = queryStates.step;
export const OnboardingStepRenderer = ({
step,
feature,
setFeature,
playgroundMode = "edit",
}: OnboardingStepRendererProps) => {
const { product, setProduct, editingState } = useProductContext();
// Get state from Zustand
const playgroundMode = useOnboardingStore((state) => state.playgroundMode);
const feature = useFeatureStore((state) => state.feature);
const product = useProductStore((s) => s.product);
const setProduct = useProductStore((s) => s.setProduct);
const sheetType = useSheetStore((s) => s.type);
const itemId = useSheetStore((s) => s.itemId);
const [trackResponse, setTrackResponse] = useState<any>(null);
const [lastUsedFeatureId, setLastUsedFeatureId] = useState<
string | undefined
@@ -43,62 +46,59 @@ export const OnboardingStepRenderer = ({
// Handle all override conditions first (before switch statement)
if (!shouldSkipOverrides) {
// Plan editing override
if (editingState?.type === "plan") {
if (sheetType === "edit-plan") {
return <EditPlanSheet isOnboarding />;
}
// New feature creation override
if (editingState?.type === "feature" && editingState.id === "new") {
if (sheetType === "new-feature") {
return <NewFeatureSheet isOnboarding />;
}
// Select feature override
if (editingState?.type === "feature" && editingState.id === "select") {
if (sheetType === "select-feature") {
return <SelectFeatureSheet isOnboarding />;
}
// Existing feature editing override
if (editingState?.type === "feature" && editingState.id !== "new") {
if (sheetType === "edit-feature" && itemId) {
const featureItems = productV2ToFeatureItems({
items: product?.items || [],
withBasePrice: true,
});
const isCurrentItem = (item: ProductItem, index: number) => {
const itemId = getItemId({ item, itemIndex: index });
return editingState.id === itemId;
const currentItemId = getItemId({ item, itemIndex: index });
return itemId === currentItemId;
};
const currentItem = featureItems.find(isCurrentItem);
// Use functional setState to avoid stale closure issues
const setCurrentItem = (updatedItem: ProductItem) => {
setProduct((prevProduct: ProductV2) => {
if (!prevProduct || !prevProduct.items) return prevProduct;
if (!product || !product.items) return;
const filteredItems = productV2ToFeatureItems({
items: prevProduct.items,
withBasePrice: true,
});
const currentItemIndex = filteredItems.findIndex((item, index) => {
const itemId = getItemId({ item, itemIndex: index });
return editingState.id === itemId;
});
if (currentItemIndex === -1) return prevProduct;
const targetItem = filteredItems[currentItemIndex];
// Find this item in the ORIGINAL items array
const originalIndex = prevProduct.items.indexOf(targetItem);
if (originalIndex === -1) return prevProduct;
// Update that specific index in the original array
const updatedItems = [...prevProduct.items];
updatedItems[originalIndex] = updatedItem;
return { ...prevProduct, items: updatedItems };
const filteredItems = productV2ToFeatureItems({
items: product.items,
withBasePrice: true,
});
const currentItemIndex = filteredItems.findIndex((item, index) => {
const currentItemId = getItemId({ item, itemIndex: index });
return itemId === currentItemId;
});
if (currentItemIndex === -1) return;
const targetItem = filteredItems[currentItemIndex];
// Find this item in the ORIGINAL items array
const originalIndex = product.items.indexOf(targetItem);
if (originalIndex === -1) return;
// Update that specific index in the original array
const updatedItems = [...product.items];
updatedItems[originalIndex] = updatedItem;
setProduct({ ...product, items: updatedItems } as ProductV2);
};
return (
@@ -125,11 +125,12 @@ export const OnboardingStepRenderer = ({
return <PlanDetailsStep />;
case OnboardingStep.FeatureCreation:
return <FeatureCreationStep feature={feature} setFeature={setFeature} />;
return <FeatureCreationStep />;
case OnboardingStep.FeatureConfiguration: {
// Find the ProductItem that corresponds to the feature being configured
// This should be the item with the feature_id matching the current feature
const featureItems = productV2ToFeatureItems({
items: product?.items || [],
withBasePrice: false, // Don't include base price when looking for feature items
@@ -141,22 +142,20 @@ export const OnboardingStepRenderer = ({
// Update the item in the product when it changes
const setCurrentItem = (updatedItem: ProductItem) => {
setProduct((prevProduct: ProductV2) => {
if (!prevProduct?.items) return prevProduct;
if (!product?.items) return;
// Find the index of the item with matching feature_id in the original items array
const originalIndex = prevProduct.items.findIndex(
(item) => item.feature_id === feature.id,
);
// Find the index of the item with matching feature_id in the original items array
const originalIndex = product.items.findIndex(
(item) => item.feature_id === feature.id,
);
if (originalIndex === -1) return prevProduct;
if (originalIndex === -1) return;
// Update that specific index in the original array
const updatedItems = [...prevProduct.items];
updatedItems[originalIndex] = updatedItem;
// Update that specific index in the original array
const updatedItems = [...product.items];
updatedItems[originalIndex] = updatedItem;
return { ...prevProduct, items: updatedItems };
});
setProduct({ ...product, items: updatedItems } as ProductV2);
};
return (

View File

@@ -1,34 +1,37 @@
import { Loader2 } from "lucide-react";
import { Button } from "@/components/v2/buttons/Button";
import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton";
import { useFeatureStore } from "@/hooks/stores/useFeatureStore";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { cn } from "@/lib/utils";
import { useOnboarding3QueryState } from "../hooks/useOnboarding3QueryState";
import { useOnboardingStore } from "../store/useOnboardingStore";
import { getStepNumber } from "../utils/onboardingUtils";
interface OnboardingStepsProps {
totalSteps: number;
currentStep: number;
onNext?: () => void;
onBack?: () => void;
onComplete?: () => void;
nextDisabled?: boolean;
backDisabled?: boolean;
nextText?: string;
backText?: string;
className?: string;
isLoading?: boolean;
}
export const OnboardingSteps = ({
totalSteps,
currentStep,
onNext,
onBack,
nextDisabled = false,
backDisabled = false,
nextText = "Next",
backText = "Back",
className,
onComplete,
isLoading = false,
}: OnboardingStepsProps) => {
export const OnboardingSteps = ({ className }: OnboardingStepsProps) => {
// Get product and feature for validation
const product = useProductStore((s) => s.product);
const feature = useFeatureStore((state) => state.feature);
// Get step from query state
const { queryStates } = useOnboarding3QueryState();
const step = queryStates.step;
// Get handlers and state from store
const isButtonLoading = useOnboardingStore((state) => state.isButtonLoading);
const handleNext = useOnboardingStore((state) => state.handleNext);
const handleBack = useOnboardingStore((state) => state.handleBack);
const validateStep = useOnboardingStore((state) => state.validateStep);
const currentStep = getStepNumber(step);
const totalSteps = 5;
const nextDisabled = !validateStep?.(step, product, feature);
const backDisabled = currentStep === 1;
const nextText = currentStep === 5 ? "Finish" : "Next";
return (
<div
className={cn(
@@ -56,26 +59,24 @@ export const OnboardingSteps = ({
<div className="flex items-center gap-1.5 flex-shrink-0">
<Button
variant="secondary"
onClick={onBack}
disabled={backDisabled}
onClick={handleBack || undefined}
disabled={backDisabled || !handleBack}
size="sm"
className="min-w-24 px-2 text-xs outline-1"
>
{backText}
Back
</Button>
<Button
<ShortcutButton
variant="primary"
onClick={currentStep === totalSteps ? onComplete : onNext}
disabled={nextDisabled || isLoading}
onClick={handleNext || undefined}
disabled={nextDisabled || isButtonLoading || !handleNext}
size="sm"
className="min-w-24 px-2 text-xs"
metaShortcut="enter"
isLoading={isButtonLoading}
>
{isLoading && (currentStep === 1 || currentStep === 2) ? (
<Loader2 className="size-3 animate-spin" />
) : (
nextText
)}
</Button>
{nextText}
</ShortcutButton>
</div>
</div>
);

View File

@@ -1,22 +1,26 @@
import { FormLabel } from "@/components/v2/form/FormLabel";
import { Input } from "@/components/v2/inputs/Input";
import { LongInput } from "@/components/v2/inputs/LongInput";
import { SheetSection } from "@/components/v2/sheets/InlineSheet";
import { useAutoSlug } from "@/hooks/common/useAutoSlug";
import { useProductContext } from "@/views/products/product/ProductContext";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { BasePriceSection } from "../../products/plan/components/edit-plan-details/BasePriceSection";
import { useOnboardingSteps } from "../hooks/useOnboardingSteps";
import { OnboardingStep } from "../utils/onboardingUtils";
export const PlanDetailsStep = () => {
const { product, setProduct } = useProductContext();
// Get product state from ProductContext (working copy being edited)
const product = useProductStore((s) => s.product);
const baseProduct = useProductStore((s) => s.baseProduct);
const setProduct = useProductStore((s) => s.setProduct);
// Check if product already exists on backend (has internal_id from database)
const isExistingProduct = !!baseProduct?.internal_id;
const { setSource, setTarget } = useAutoSlug({
state: product,
setState: setProduct,
sourceKey: "name",
targetKey: "id",
disableAutoSlug: isExistingProduct,
});
const { step } = useOnboardingSteps();
return (
<>
@@ -49,12 +53,12 @@ export const PlanDetailsStep = () => {
Used to refer to this product when using Autumn's APIs or SDKs
</span>
</div>
{step === OnboardingStep.Playground && (
{/* {step === OnboardingStep.Playground && product && (
<div className="col-span-1">
<FormLabel>Description</FormLabel>
<LongInput
placeholder="eg. This plan includes 100 credits"
value={product?.description || ""}
value={(product).description || ""}
onChange={(e) =>
setProduct({
...product,
@@ -63,7 +67,7 @@ export const PlanDetailsStep = () => {
}
/>
</div>
)}
)} */}
</div>
</div>
</SheetSection>

View File

@@ -1,5 +1,5 @@
import type { ProductV2 } from "@autumn/shared";
import { SheetHeader } from "@/components/v2/sheets/InlineSheet";
import { useOnboarding3QueryState } from "../hooks/useOnboarding3QueryState";
import {
getStepNumber,
OnboardingStep,
@@ -7,29 +7,11 @@ import {
} from "../utils/onboardingUtils";
import { PlaygroundToolbar } from "./playground-step/PlaygroundToolbar";
interface StepHeaderProps {
step: OnboardingStep;
selectedProductId: string;
products: ProductV2[];
onPlanSelect: (planId: string) => void;
onCreatePlanSuccess: (newProduct: ProductV2) => Promise<void>;
playgroundMode?: "edit" | "preview";
setPlaygroundMode?: (mode: "edit" | "preview") => void;
sheet?: string | null;
editingState?: { type: "plan" | "feature" | null; id: string | null };
}
export const StepHeader = () => {
// Get step from query state
const { queryStates } = useOnboarding3QueryState();
const step = queryStates.step;
export const StepHeader = ({
step,
selectedProductId,
products,
onPlanSelect,
onCreatePlanSuccess,
playgroundMode = "edit",
setPlaygroundMode,
sheet,
editingState,
}: StepHeaderProps) => {
const stepNum = getStepNumber(step);
const config = stepConfig[step];
@@ -43,14 +25,7 @@ export const StepHeader = ({
className="p-0 sticky"
isOnboarding={true}
/>
<PlaygroundToolbar
playgroundMode={playgroundMode ?? "edit"}
setPlaygroundMode={setPlaygroundMode ?? (() => {})}
selectedProductId={selectedProductId}
products={products}
onPlanSelect={onPlanSelect}
onCreatePlanSuccess={onCreatePlanSuccess}
/>
<PlaygroundToolbar />
</div>
);
}

View File

@@ -1,4 +1,3 @@
import type { ProductV2 } from "@autumn/shared";
import {
PencilSimpleIcon,
SquareSplitHorizontalIcon,
@@ -11,25 +10,23 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/v2/selects/Select";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useProductStore } from "@/hooks/stores/useProductStore";
import CreatePlanDialog from "@/views/products/products/components/CreatePlanDialog";
import { useOnboardingStore } from "../../store/useOnboardingStore";
interface PlaygroundToolbarProps {
playgroundMode: "edit" | "preview";
setPlaygroundMode: (mode: "edit" | "preview") => void;
selectedProductId: string;
products: ProductV2[];
onPlanSelect: (planId: string) => void;
onCreatePlanSuccess: (newProduct: ProductV2) => Promise<void>;
}
export const PlaygroundToolbar = () => {
// Get products from query
const { products } = useProductsQuery();
export const PlaygroundToolbar = ({
playgroundMode,
setPlaygroundMode,
selectedProductId,
products,
onPlanSelect,
onCreatePlanSuccess,
}: PlaygroundToolbarProps) => {
// Get current product and playground mode from stores
const product = useProductStore((s) => s.product);
const playgroundMode = useOnboardingStore((s) => s.playgroundMode);
const setPlaygroundMode = useOnboardingStore((s) => s.setPlaygroundMode);
// Get handlers from store
const handlePlanSelect = useOnboardingStore((s) => s.handlePlanSelect);
const onCreatePlanSuccess = useOnboardingStore((s) => s.onCreatePlanSuccess);
return (
<div className="flex gap-2 items-center justify-between">
<GroupedTabButton
@@ -54,12 +51,15 @@ export const PlaygroundToolbar = ({
]}
/>
<div className="flex gap-2 items-center">
<Select value={selectedProductId} onValueChange={onPlanSelect}>
<Select
value={product?.id}
onValueChange={(id) => handlePlanSelect?.(id)}
>
<SelectTrigger className="!h-6 text-body px-2 py-1 min-w-0 max-w-[120px] overflow-hidden text-ellipsis whitespace-nowrap">
<SelectValue placeholder="Select plan" className="truncate" />
</SelectTrigger>
<SelectContent>
{products.map((prod) => (
{products?.map((prod) => (
<SelectItem key={prod.id} value={prod.id} className="text-body">
<span className="truncate block max-w-[100px]">
{prod.name}
@@ -69,7 +69,7 @@ export const PlaygroundToolbar = ({
</SelectContent>
</Select>
<CreatePlanDialog
onSuccess={onCreatePlanSuccess}
onSuccess={onCreatePlanSuccess || undefined}
size="sm"
buttonClassName="!h-6 !px-2 text-body"
/>

View File

@@ -1,64 +1,50 @@
import type { ProductV2 } from "@autumn/shared";
import type { AxiosInstance } from "axios";
import { useCallback } from "react";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useHasChanges, useProductStore } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { updateProduct } from "@/views/products/product/utils/updateProduct";
interface FeatureConfigActionsProps {
product: ProductV2 | null;
diff: { hasChanges: boolean };
axiosInstance: AxiosInstance;
handleRefetch: () => Promise<void>;
setSheet: (sheet: string | null) => void;
setEditingState: (state: {
type: "plan" | "feature" | null;
id: string | null;
}) => void;
}
export const useFeatureConfigActions = () => {
const axiosInstance = useAxiosInstance();
// Get product from product store (working copy to save)
const product = useProductStore((s) => s.product);
const hasChanges = useHasChanges();
// Get products refetch
const { refetch: refetchProducts } = useProductsQuery();
// Get sheet store
const setSheet = useSheetStore((state) => state.setSheet);
export const useFeatureConfigActions = ({
product,
diff,
axiosInstance,
handleRefetch,
setSheet,
setEditingState,
}: FeatureConfigActionsProps) => {
// Save product changes before proceeding to playground
const handleProceed = useCallback(async (): Promise<boolean> => {
// If no changes, just open the sheet and proceed
if (!diff.hasChanges) {
setSheet("edit-plan");
setEditingState({ type: "plan", id: null });
if (!hasChanges) {
setSheet({ type: "edit-plan" });
return true;
}
// Save changes
const { updateProduct } = await import(
"../../../products/product/utils/updateProduct"
);
const saved = await updateProduct({
axiosInstance,
productId: product.id,
product: product as ProductV2,
onSuccess: async () => {
await handleRefetch();
// Refetch products so useInitProductAndFeature can update baseProduct
await refetchProducts();
},
});
if (!saved) return false;
// Open edit-plan sheet after successful save
setSheet("edit-plan");
setEditingState({ type: "plan", id: null });
setSheet({ type: "edit-plan" });
return true;
}, [
diff.hasChanges,
axiosInstance,
product,
handleRefetch,
setSheet,
setEditingState,
]);
}, [hasChanges, axiosInstance, product, setSheet, refetchProducts]);
return {
handleProceed,

View File

@@ -1,109 +1,93 @@
import type {
CreateFeature,
Feature,
ProductItem,
ProductV2,
} from "@autumn/shared";
import { isPriceItem } from "@autumn/shared";
import type { AxiosInstance } from "axios";
import { type MutableRefObject, useCallback } from "react";
import type { CreateFeature } from "@autumn/shared";
import { apiFeatureToDbFeature, CreateFeatureSchema } from "@autumn/shared";
import type { AxiosError } from "axios";
import { useCallback } from "react";
import { toast } from "sonner";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { createFeature, createProductItem } from "../../utils/onboardingUtils";
import { useFeatureStore } from "@/hooks/stores/useFeatureStore";
import { FeatureService } from "@/services/FeatureService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr } from "@/utils/genUtils";
interface FeatureCreationActionsProps {
feature: Feature | CreateFeature | null;
product: ProductV2 | null;
axiosInstance: AxiosInstance;
featureCreatedRef: MutableRefObject<{
created: boolean;
latestId: string | null;
}>;
setFeature: (feature: Feature | CreateFeature | null) => void;
setProduct: (product: ProductV2) => void;
setBaseProduct: (product: ProductV2) => void;
}
export const useFeatureCreationActions = () => {
const axiosInstance = useAxiosInstance();
const { refetch: refetchFeatures } = useFeaturesQuery();
export const useFeatureCreationActions = ({
feature,
product,
axiosInstance,
featureCreatedRef,
setFeature,
setProduct,
setBaseProduct,
}: FeatureCreationActionsProps) => {
const { features, refetch: refetchFeatures } = useFeaturesQuery();
// Get state from feature store
const feature = useFeatureStore((state) => state.feature);
const baseFeature = useFeatureStore((state) => state.baseFeature);
const setBaseFeature = useFeatureStore((state) => state.setBaseFeature);
const setFeature = useFeatureStore((state) => state.setFeature);
// Create feature and add to product
// Create or update feature
const handleProceed = useCallback(async (): Promise<boolean> => {
// 1. If feature already exists, update it, if not create it
const createdFeature = await createFeature(
feature as CreateFeature,
axiosInstance,
featureCreatedRef,
);
if (!createdFeature) return false;
await refetchFeatures(); // Refresh features list
// Create ProductItem and add to product immediately for live editing
const newItem = createProductItem(createdFeature);
setFeature(createdFeature);
// Add feature item to product (preserving any existing base price item)
if (product && "items" in product) {
const existingItems = product.items || [];
// Check if we already have a feature item (from previous onboarding attempts)
const existingFeatureItemIndex = existingItems.findIndex(
(item: ProductItem) => item.feature_id && !isPriceItem(item),
);
let updatedItems: typeof existingItems;
if (existingFeatureItemIndex !== -1) {
// Update existing feature item with new feature_id and feature_type
updatedItems = [...existingItems];
const oldItem = updatedItems[existingFeatureItemIndex];
updatedItems[existingFeatureItemIndex] = {
...updatedItems[existingFeatureItemIndex],
feature_id: createdFeature.id,
feature_type: newItem.feature_type,
};
console.log("FeatureCreationActions - updated existing product item:", {
oldFeatureType: oldItem.feature_type,
newFeatureType: newItem.feature_type,
featureId: createdFeature.id,
changed: oldItem.feature_type !== newItem.feature_type,
});
} else {
// Add new feature item, preserving any existing base price items
updatedItems = [...existingItems, newItem];
}
const updatedProduct = {
...product,
items: updatedItems,
};
// Update local state (don't save yet - item needs configuration in step 3)
setProduct(updatedProduct);
setBaseProduct(updatedProduct);
// Validate feature data
const result = CreateFeatureSchema.safeParse(feature);
if (result.error) {
toast.error("Invalid feature", {
description: result.error.issues.map((x) => x.message).join(".\n"),
});
return false;
}
return true;
try {
let updatedFeature: CreateFeature;
// If baseFeature exists (update mode), update it
if (baseFeature?.id) {
const { data } = await FeatureService.updateFeature(
axiosInstance,
baseFeature.id,
{
name: feature.name,
id: feature.id,
type: feature.type,
config: feature.config,
},
);
updatedFeature = apiFeatureToDbFeature({ apiFeature: data });
toast.success(`Feature "${feature.name}" updated successfully!`);
} else {
// Create new feature
const { data } = await FeatureService.createFeature(axiosInstance, {
name: feature.name,
id: feature.id,
type: feature.type,
config: feature.config,
});
updatedFeature = apiFeatureToDbFeature({ apiFeature: data });
toast.success(`Feature "${feature.name}" created successfully!`);
}
console.log("Updated feature", updatedFeature);
if (!updatedFeature?.id) return false;
await refetchFeatures(); // Refresh features list
// Update both base and working copy after successful save
setBaseFeature(updatedFeature);
setFeature(updatedFeature);
// Note: Feature item creation is now handled by useInitFeatureItem when entering Step 3
// This ensures proper initialization on both normal flow and refresh scenarios
return true;
} catch (error: unknown) {
console.error("Failed to create/update feature:", error);
toast.error(
getBackendErr(error as AxiosError, "Failed to create/update feature"),
);
return false;
}
}, [
feature,
baseFeature,
axiosInstance,
featureCreatedRef,
refetchFeatures,
setBaseFeature,
setFeature,
product,
setProduct,
setBaseProduct,
]);
return {

View File

@@ -1,64 +1,47 @@
import type { ProductV2 } from "@autumn/shared";
import type { AxiosInstance } from "axios";
import { type MutableRefObject, useCallback } from "react";
import { useCallback } from "react";
import { toast } from "sonner";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { updateProduct } from "@/views/products/product/utils/updateProduct";
import { createProduct } from "../../utils/onboardingUtils";
interface PlanDetailsActionsProps {
product: ProductV2 | null;
baseProduct: ProductV2;
axiosInstance: AxiosInstance;
productCreatedRef: MutableRefObject<{
created: boolean;
latestId: string | null;
}>;
setBaseProduct: (product: ProductV2) => void;
}
export const usePlanDetailsActions = () => {
const axiosInstance = useAxiosInstance();
const { refetch: refetchProducts } = useProductsQuery();
export const usePlanDetailsActions = ({
product,
baseProduct,
axiosInstance,
productCreatedRef,
setBaseProduct,
}: PlanDetailsActionsProps) => {
const { products, refetch: refetchProducts } = useProductsQuery();
// Get product from product store (working copy)
const product = useProductStore((s) => s.product);
const baseProduct = useProductStore((s) => s.baseProduct);
// Create product and update base state
// Create or update product
const handleProceed = useCallback(async (): Promise<boolean> => {
// Check if base product exists in products query
if (!product) return false;
let newProduct: ProductV2;
if (products.find((p) => p.id === baseProduct.id)) {
// If baseProduct exists (update mode), update it
if (baseProduct?.id) {
newProduct = await updateProduct({
axiosInstance,
productId: baseProduct.id,
product: product as ProductV2,
onSuccess: async () => {},
});
toast.success("Product updated successfully");
} else {
newProduct = await createProduct(
product,
axiosInstance,
productCreatedRef,
);
// Create new product
newProduct = await createProduct(product, axiosInstance);
}
if (!newProduct) return false;
setBaseProduct(newProduct);
// Refetch products - useOnboardingProductSync will handle syncing product/baseProduct
await refetchProducts();
return true;
}, [
product,
baseProduct,
products,
axiosInstance,
productCreatedRef,
setBaseProduct,
]);
}, [product, baseProduct, axiosInstance, refetchProducts]);
return {
handleProceed,

View File

@@ -1,157 +1,118 @@
import type { ProductV2 } from "@autumn/shared";
import type { AxiosInstance } from "axios";
import { type MutableRefObject, useCallback } from "react";
import { useCallback, useMemo } from "react";
import { useNavigate } from "react-router";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useEnv } from "@/utils/envUtils";
import {
handleBackNavigation,
handleCreatePlanSuccess,
handlePlanSelection,
type OnboardingStep,
} from "../../utils/onboardingUtils";
import { OnboardingStep } from "../../utils/onboardingUtils";
interface SharedActionsProps {
step: OnboardingStep;
baseProduct: ProductV2;
selectedProductId: string;
product: ProductV2 | null;
productCreatedRef: MutableRefObject<{
created: boolean;
latestId: string | null;
}>;
featureCreatedRef: MutableRefObject<{
created: boolean;
latestId: string | null;
}>;
axiosInstance: AxiosInstance;
setBaseProduct: (product: ProductV2) => void;
setProduct: (product: ProductV2) => void;
setSelectedProductId: (id: string) => void;
setSheet: (sheet: string | null) => void;
setEditingState: (state: {
type: "plan" | "feature" | null;
id: string | null;
}) => void;
popStep: () => void;
refetchProducts: () => Promise<unknown>;
refetchProducts: () => Promise<void>;
products: ProductV2[];
}
export const useSharedActions = ({
step,
baseProduct,
selectedProductId,
product,
productCreatedRef,
featureCreatedRef,
axiosInstance,
setBaseProduct,
setProduct,
setSelectedProductId,
setSheet,
setEditingState,
popStep,
refetchProducts,
products,
}: SharedActionsProps) => {
const navigate = useNavigate();
const env = useEnv();
// Get product from product store (working copy)
const product = useProductStore((s) => s.product);
const setProduct = useProductStore((s) => s.setProduct);
const baseProduct = useProductStore((s) => s.baseProduct);
const setBaseProduct = useProductStore((s) => s.setBaseProduct);
const setSheet = useSheetStore((state) => state.setSheet);
// Handle plan selection from dropdown
const handlePlanSelect = useCallback(
async (planId: string) => {
try {
await handlePlanSelection(
planId,
selectedProductId,
baseProduct,
setBaseProduct,
setProduct,
setSelectedProductId,
setSheet,
setEditingState,
axiosInstance,
);
} catch (_) {
setSelectedProductId(product?.id || "");
if (!planId || planId === product.id) return;
// Find the product in the already-fetched products list
const selectedProduct = products.find((p) => p.id === planId);
if (!selectedProduct) {
console.error("Product not found in products list:", planId);
return;
}
// Set as base product and working product
setBaseProduct(selectedProduct);
setProduct(selectedProduct);
// Keep the edit-plan sheet open
setSheet({ type: "edit-plan" });
},
[
selectedProductId,
baseProduct,
setBaseProduct,
setProduct,
axiosInstance,
product?.id,
setSheet,
setEditingState,
setSelectedProductId,
],
[product.id, products, setBaseProduct, setProduct, setSheet],
);
// Handle back navigation
const handleBack = useCallback(async () => {
setSheet(null);
setEditingState({ type: null, id: null });
const { closeSheet } = useSheetStore.getState();
closeSheet();
await handleBackNavigation(
step,
productCreatedRef,
featureCreatedRef,
baseProduct,
setBaseProduct,
setSelectedProductId,
axiosInstance,
);
// If we're on Step 3 (FeatureConfiguration), reset product to baseProduct
// This removes the half-configured feature item that was added in Step 2
if (step === OnboardingStep.FeatureConfiguration && baseProduct) {
setProduct(baseProduct);
}
// handleBackNavigation logic is mostly commented out in utils
// Just pop the step
popStep();
}, [
step,
productCreatedRef,
featureCreatedRef,
baseProduct,
setBaseProduct,
setSelectedProductId,
axiosInstance,
popStep,
setSheet,
setEditingState,
]);
}, [popStep, step, baseProduct, setProduct]);
// Handle create plan success from dialog
const onCreatePlanSuccess = useCallback(
async (newProduct: ProductV2) => {
try {
await handleCreatePlanSuccess(
newProduct,
axiosInstance,
setBaseProduct,
setSelectedProductId,
setSheet,
setEditingState,
async () => {
await refetchProducts();
},
);
// Refetch products to update the list
// useOnboardingProductSync will sync baseProduct with the backend version
await refetchProducts();
// Set the newly created product as base and working product
setBaseProduct(newProduct);
setProduct(newProduct);
// Open edit-plan sheet
setSheet({ type: "edit-plan" });
} catch (error) {
console.error("Failed to load new plan:", error);
const { navigateTo } = await import("@/utils/genUtils");
navigateTo(`/products/${newProduct.id}`, navigate, env);
}
},
[
axiosInstance,
setBaseProduct,
setSelectedProductId,
setSheet,
setEditingState,
refetchProducts,
navigate,
env,
],
[setBaseProduct, setProduct, refetchProducts, setSheet, navigate, env],
);
return {
handlePlanSelect,
handleBack,
onCreatePlanSuccess,
};
// Handle delete plan success from dialog
const handleDeletePlanSuccess = useCallback(async () => {
// Refetch products - useOnboardingProductSync will automatically:
// - Redirect to step 1 if no products left
// - Fallback to products[0] if current product was deleted but others exist
// - Sync both baseProduct and product
await refetchProducts();
}, [refetchProducts]);
return useMemo(
() => ({
handlePlanSelect,
handleBack,
onCreatePlanSuccess,
handleDeletePlanSuccess,
}),
[
handlePlanSelect,
handleBack,
onCreatePlanSuccess,
handleDeletePlanSuccess,
],
);
};

View File

@@ -1,14 +1,15 @@
import type { CreateFeature, Feature, ProductV2 } from "@autumn/shared";
import { type MutableRefObject, useCallback } from "react";
import { useCallback } from "react";
import { useNavigate } from "react-router";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useFeatureStore } from "@/hooks/stores/useFeatureStore";
import { useHasChanges, useProductStore } from "@/hooks/stores/useProductStore";
import { useEnv } from "@/utils/envUtils";
import { navigateTo } from "@/utils/genUtils";
import { useOnboardingStore } from "../../store/useOnboardingStore";
import { getNextStep, OnboardingStep } from "../../utils/onboardingUtils";
import { useFeatureConfigActions } from "./useFeatureConfigActions";
import { useFeatureCreationActions } from "./useFeatureCreationActions";
import { usePlanDetailsActions } from "./usePlanDetailsActions";
import { useSharedActions } from "./useSharedActions";
interface StepActionsProps {
// Flow state
@@ -17,117 +18,35 @@ interface StepActionsProps {
popStep: () => void;
validateStep: (
step: OnboardingStep,
product: ProductV2 | null,
product: ProductV2 | undefined,
feature: Feature | CreateFeature | null,
) => boolean;
// Data
product: ProductV2 | null;
setProduct: (product: ProductV2) => void;
baseProduct: ProductV2;
setBaseProduct: (product: ProductV2) => void;
feature: Feature | CreateFeature | null;
setFeature: (feature: Feature | CreateFeature | null) => void;
diff: { hasChanges: boolean };
selectedProductId: string;
setSelectedProductId: (id: string) => void;
productCreatedRef: MutableRefObject<{
created: boolean;
latestId: string | null;
}>;
featureCreatedRef: MutableRefObject<{
created: boolean;
latestId: string | null;
}>;
// External actions
handleRefetch: () => Promise<void>;
refetchProducts: () => Promise<unknown>;
refetchFeatures: () => Promise<unknown>;
// UI state setters
setSheet: (sheet: string | null) => void;
setEditingState: (state: {
type: "plan" | "feature" | null;
id: string | null;
}) => void;
setIsButtonLoading: (loading: boolean) => void;
// Shared actions (passed from parent)
sharedActions: {
handlePlanSelect: (planId: string) => Promise<void>;
handleBack: () => Promise<void>;
onCreatePlanSuccess: (newProduct: ProductV2) => Promise<void>;
};
}
export const useStepActions = (props: StepActionsProps) => {
const {
step,
pushStep,
popStep,
validateStep,
product,
setProduct,
baseProduct,
setBaseProduct,
feature,
setFeature,
diff,
selectedProductId,
setSelectedProductId,
productCreatedRef,
featureCreatedRef,
handleRefetch,
refetchProducts,
refetchFeatures,
setSheet,
setEditingState,
setIsButtonLoading,
} = props;
const { step, pushStep, validateStep, sharedActions } = props;
const navigate = useNavigate();
const env = useEnv();
const axiosInstance = useAxiosInstance();
// Step-specific action hooks
const planDetailsActions = usePlanDetailsActions({
product,
baseProduct,
axiosInstance,
productCreatedRef,
setBaseProduct,
});
// Get product from product store
const product = useProductStore((s) => s.product);
const featureCreationActions = useFeatureCreationActions({
feature,
product,
axiosInstance,
featureCreatedRef,
setFeature,
setProduct,
setBaseProduct,
});
// Get state from Zustand
const feature = useFeatureStore((state) => state.feature);
const setIsButtonLoading = useOnboardingStore((s) => s.setIsButtonLoading);
const hasChanges = useHasChanges();
const featureConfigActions = useFeatureConfigActions({
product,
diff,
axiosInstance,
handleRefetch,
setSheet,
setEditingState,
});
// Shared actions
const sharedActions = useSharedActions({
step,
baseProduct,
selectedProductId,
product,
productCreatedRef,
featureCreatedRef,
axiosInstance,
setBaseProduct,
setProduct,
setSelectedProductId,
setSheet,
setEditingState,
popStep,
refetchProducts,
});
// Step-specific action hooks (they access state directly now)
const planDetailsActions = usePlanDetailsActions();
const featureCreationActions = useFeatureCreationActions();
const featureConfigActions = useFeatureConfigActions();
// Main navigation handler
const handleNext = useCallback(async () => {
@@ -139,8 +58,10 @@ export const useStepActions = (props: StepActionsProps) => {
// Set loading for steps 1 and 2
if (
step === OnboardingStep.PlanDetails ||
step === OnboardingStep.FeatureCreation
step === OnboardingStep.FeatureCreation ||
(step === OnboardingStep.FeatureConfiguration && hasChanges)
) {
console.log("setting loading to true", step);
setIsButtonLoading(true);
}
@@ -187,6 +108,7 @@ export const useStepActions = (props: StepActionsProps) => {
navigate,
env,
setIsButtonLoading,
hasChanges,
]);
return {

View File

@@ -0,0 +1,62 @@
import type { ProductItem } from "@autumn/shared";
import { useEffect } from "react";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useFeatureStore } from "@/hooks/stores/useFeatureStore";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { createProductItem, OnboardingStep } from "../utils/onboardingUtils";
import { useOnboarding3QueryState } from "./useOnboarding3QueryState";
/**
* Hook to ensure Step 3 has a valid feature item that matches baseFeature
* Runs whenever on Step 3 and ensures the feature item is in sync
*/
export const useInitFeatureItem = () => {
// Get step from query state
const { queryStates } = useOnboarding3QueryState();
const step = queryStates.step;
// Get features from query
const { features } = useFeaturesQuery();
// Get product state from store
const product = useProductStore((state) => state.product);
const setProduct = useProductStore((state) => state.setProduct);
const setBaseProduct = useProductStore((state) => state.setBaseProduct);
// Get state from Zustand
const baseFeature = useFeatureStore((state) => state.baseFeature);
// biome-ignore lint/correctness/useExhaustiveDependencies: Don't depend on setProduct/setBaseProduct
useEffect(() => {
if (!product?.items || !baseFeature?.id) return;
// Find existing feature item (non-price item with a feature_id)
const existingFeatureItemIndex = product.items.findIndex(
(item: ProductItem) => item.feature_id && !item.price_id,
);
const updatedItems = [...product.items];
let needsUpdate = false;
if (existingFeatureItemIndex === -1) {
// Create feature item only on Step 3
if (step === OnboardingStep.FeatureConfiguration && features?.length) {
updatedItems.push(createProductItem(baseFeature));
needsUpdate = true;
}
} else if (updatedItems[existingFeatureItemIndex].feature_id !== baseFeature.id) {
// Update feature_id if it changed
updatedItems[existingFeatureItemIndex] = {
...updatedItems[existingFeatureItemIndex],
feature_id: baseFeature.id,
};
needsUpdate = true;
}
if (needsUpdate) {
const updatedProduct = { ...product, items: updatedItems };
setProduct(updatedProduct);
setBaseProduct(updatedProduct);
}
}, [step, product?.items, baseFeature?.id, features?.length]);
};

View File

@@ -0,0 +1,33 @@
import { useEffect, useRef } from "react";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useFeatureStore } from "@/hooks/stores/useFeatureStore";
/**
* Hook to initialize feature when resuming onboarding
* Loads first feature from existing data if available
* Note: Product sync is handled by useProductSync
*/
export const useInitFeature = () => {
const { features } = useFeaturesQuery();
// Feature store
const baseFeature = useFeatureStore((state) => state.baseFeature);
const setBaseFeature = useFeatureStore((state) => state.setBaseFeature);
const setFeature = useFeatureStore((state) => state.setFeature);
const hasInitialized = useRef(false);
// Initialize feature if available (only once)
useEffect(() => {
if (!features || features.length === 0) return;
if (hasInitialized.current) return;
// Load first feature if not already set in store
if (!baseFeature) {
const firstFeature = features[0];
setBaseFeature(firstFeature);
setFeature(firstFeature);
hasInitialized.current = true;
}
}, [features, baseFeature, setBaseFeature, setFeature]);
};

View File

@@ -1,191 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { usePlanData } from "../../products/plan/hooks/usePlanData";
import { useOnboardingState } from "./useOnboardingState";
import { useInitProductAndFeature } from "./useInitProductAndFeature";
/**
* Hook to manage onboarding initialization
*
* Key architecture:
* - Product state is managed in useProductStore (baseProduct and working product)
* - This hook just initializes the feature item for step 3
*/
export const useOnboardingData = () => {
const axiosInstance = useAxiosInstance();
const {
products,
isLoading: productsLoading,
refetch: refetchProducts,
} = useProductsQuery();
const {
features,
isLoading: featuresLoading,
refetch: refetchFeatures,
} = useFeaturesQuery();
const {
baseProduct,
setBaseProduct,
feature,
setFeature,
productCreatedRef,
featureCreatedRef,
isButtonLoading,
setIsButtonLoading,
} = useOnboardingState();
const [selectedProductId, setSelectedProductId] = useState<string>("");
// Use baseProduct as the original for plan data
const originalProduct = baseProduct;
const { product, setProduct, diff } = usePlanData({ originalProduct });
// // Load product data by ID
// const loadProductData = useCallback(
// async (productId: string) => {
// try {
// const response = await axiosInstance.get(
// `/products/${productId}/data2`,
// );
// setBaseProduct(response.data.product);
// return response.data.product;
// } catch (error) {
// console.error("Failed to load product:", error);
// return null;
// }
// },
// [axiosInstance, setBaseProduct],
// );
// Initialize with first available product and feature on mount
useEffect(() => {
// Only run when queries have loaded
if (!products || !features) return;
// Load first product if available and not already set in ref
if (products.length > 0 && !productCreatedRef.current.created) {
const firstProduct = products[0];
productCreatedRef.current = {
created: true,
latestId: firstProduct.id,
};
// loadProductData(firstProduct.id);
setBaseProduct(firstProduct);
}
// Load first feature if available and not already set in ref
if (features.length > 0 && !featureCreatedRef.current.created) {
const firstFeature = features[0];
featureCreatedRef.current = {
created: true,
latestId: firstFeature.id,
};
setFeature({
...firstFeature,
config: firstFeature.config || {},
});
}
}, [
products,
features,
productCreatedRef,
featureCreatedRef,
setFeature,
setBaseProduct,
// loadProductData,
]);
// Sync selectedProductId with product ID when it changes
useEffect(() => {
if (product?.id && selectedProductId !== product.id) {
setSelectedProductId(product.id);
}
}, [product?.id, selectedProductId]);
// // Initialize with first available product and feature for completed onboarding
// const initializeWithExistingData = useCallback(async () => {
// console.log("Features:", features);
// console.log("Products:", products);
// if (
// !products ||
// !features ||
// products.length === 0 ||
// features.length === 0
// ) {
// return false;
// }
// const firstProduct = products[0];
// const firstFeature = features[0];
// // Mark as existing for resumability
// productCreatedRef.current = {
// created: true,
// latestId: firstProduct.id,
// };
// featureCreatedRef.current = {
// created: true,
// latestId: firstFeature.id,
// };
// // Set feature with proper structure
// setFeature({
// ...firstFeature,
// config: firstFeature.config || {},
// });
// // Load product data
// const productData = await loadProductData(firstProduct.id);
// return !!productData;
// }, [
// products,
// features,
// productCreatedRef,
// featureCreatedRef,
// setFeature,
// loadProductData,
// ]);
// Refetch product data
const handleRefetch = () => {};
// useEffect(() => {
// console.log("[useOnboardingData] Feature: ", feature);
// }, [feature]);
const isQueryLoading = useMemo(() => {
return productsLoading || featuresLoading;
}, [productsLoading, featuresLoading]);
return {
// Core data
product,
setProduct,
baseProduct,
setBaseProduct,
feature,
setFeature,
diff,
// Product selection
selectedProductId,
setSelectedProductId,
products,
features,
// Refs for resumability
productCreatedRef,
featureCreatedRef,
// Loading state
isQueryLoading,
isButtonLoading,
setIsButtonLoading,
// Actions
// loadProductData,
handleRefetch,
refetchProducts,
refetchFeatures,
};
useInitProductAndFeature();
};

View File

@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useOnboardingSteps } from "./useOnboardingSteps";
import { useOnboarding3QueryState } from "./useOnboarding3QueryState";
import { useOnboardingSteps } from "./useOnboardingSteps";
interface OnboardingFlowState {
playgroundMode: "edit" | "preview";
@@ -76,7 +76,9 @@ export const useOnboardingFlow = () => {
];
const currentIndex = stepOrder.indexOf(queryStates.step);
if (currentIndex > 0) {
setQueryStates({ step: stepOrder[currentIndex - 1] as typeof queryStates.step });
setQueryStates({
step: stepOrder[currentIndex - 1] as typeof queryStates.step,
});
}
}, [queryStates.step, setQueryStates]);

View File

@@ -1,39 +1,55 @@
import type { ProductItem, ProductV2 } from "@autumn/shared";
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router";
import { useEnv } from "@/utils/envUtils";
import { useEffect, useRef } from "react";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { useOnboardingStore } from "../store/useOnboardingStore";
import { OnboardingStep } from "../utils/onboardingUtils";
import { useSharedActions } from "./actions/useSharedActions";
import { useStepActions } from "./actions/useStepActions";
import { useOnboardingData } from "./useOnboardingData";
import { useOnboardingFlow } from "./useOnboardingFlow";
/**
* Main orchestrator for onboarding logic
*
* This hook handles side effects and auto-initialization.
* Most state should be accessed directly from Zustand in components!
*
* Only returns action handlers (handleNext, handleBack, etc.)
*/
export const useOnboardingLogic = () => {
const navigate = useNavigate();
const env = useEnv();
const hasInitializedResumability = useRef(false);
// Centralized UI state - keeping this here as discussed since it's shared across many components
const [sheet, setSheet] = useState<string | null>(null);
const [editingState, setEditingState] = useState<{
type: "plan" | "feature" | null;
id: string | null;
}>({ type: null, id: null });
// Get queries
const { products, refetch: refetchProducts } = useProductsQuery();
const { features } = useFeaturesQuery();
// Use the focused hooks
// Set isOnboarding flag on mount
useEffect(() => {
const { setIsOnboarding } = useOnboardingStore.getState();
setIsOnboarding(true);
return () => {
setIsOnboarding(false);
};
}, []);
// Get flow state
const flowHook = useOnboardingFlow();
const dataHook = useOnboardingData();
// Get state from sheet store
const setSheet = useSheetStore((s) => s.setSheet);
const closeSheet = useSheetStore((s) => s.closeSheet);
// Initialize shared actions (handles plan selection, back navigation, etc.)
const sharedActions = useSharedActions({
step: flowHook.step,
popStep: flowHook.popStep,
refetchProducts,
products: products || [],
});
// Initialize with existing data ONLY if user has completed onboarding AND auto-skipped to playground
// This ensures data seeding only happens when onboarding is fully complete (both products AND features exist)
// Use ref to prevent re-initialization and API spam
useEffect(() => {
// Only initialize if ALL conditions are met:
// 1. Not already initialized
// 2. Onboarding is FULLY completed (hasCompletedOnboarding = products >= 1 AND features >= 1)
// 3. Data is loaded
// 4. User was auto-skipped to playground (step 4)
// 5. In preview mode (not edit mode)
// 6. ADDITIONAL SAFEGUARD: Explicitly verify that products and features exist
if (
!hasInitializedResumability.current &&
flowHook.hasCompletedOnboarding &&
@@ -42,12 +58,11 @@ export const useOnboardingLogic = () => {
flowHook.playgroundMode === "preview"
) {
// Additional safeguard: Double-check that we actually have both products and features
// This prevents any edge cases where hasCompletedOnboarding might be true incorrectly
if (
dataHook.products &&
dataHook.features &&
dataHook.products.length >= 1 &&
dataHook.features.length >= 1
products &&
features &&
products.length >= 1 &&
features.length >= 1
) {
hasInitializedResumability.current = true;
}
@@ -57,100 +72,58 @@ export const useOnboardingLogic = () => {
flowHook.isLoading,
flowHook.step,
flowHook.playgroundMode,
dataHook,
products,
features,
]);
// Auto-open edit-plan sheet when entering step 3 (FeatureConfiguration) or step 4 (Playground) in edit mode
// For step 3, use the tracked feature from dataHook
// Clear sheet when entering step 5 (Integration)
// Auto-open edit-plan sheet when entering step 4 (Playground) in edit mode
// Close sheet when leaving step 4 or entering Integration
useEffect(() => {
if (flowHook.step === OnboardingStep.FeatureConfiguration) {
// Use the feature that was created in step 2 or loaded during resumability
if (dataHook.feature?.id && dataHook.product?.items) {
// Find the item index that matches the feature
const itemIndex = dataHook.product.items.findIndex(
(item: ProductItem) => item.feature_id === dataHook.feature.id,
);
if (itemIndex !== -1) {
const itemId = `item-${itemIndex}`;
setSheet("edit-plan");
setEditingState({ type: "feature", id: itemId });
}
}
} else if (
if (
flowHook.step === OnboardingStep.Playground &&
flowHook.playgroundMode === "edit"
) {
setSheet("edit-plan");
setEditingState({ type: "plan", id: null });
} else if (flowHook.step === OnboardingStep.Integration) {
setSheet(null);
setEditingState({ type: null, id: null });
setSheet({ type: "edit-plan" });
} else if (
flowHook.step === OnboardingStep.Integration ||
flowHook.step === OnboardingStep.FeatureConfiguration
) {
closeSheet();
}
}, [
flowHook.step,
flowHook.playgroundMode,
dataHook.feature?.id,
dataHook.product?.items,
]);
}, [flowHook.step, flowHook.playgroundMode, setSheet, closeSheet]);
// Create actions hook with all required props
// Create actions hook (pass shared actions)
const stepActionsHook = useStepActions({
step: flowHook.step,
pushStep: flowHook.pushStep,
popStep: flowHook.popStep,
validateStep: flowHook.validateStep,
product: dataHook.product as unknown as ProductV2,
setProduct: dataHook.setProduct,
baseProduct: dataHook.baseProduct,
setBaseProduct: dataHook.setBaseProduct,
feature: dataHook.feature,
setFeature: dataHook.setFeature,
diff: dataHook.diff,
selectedProductId: dataHook.selectedProductId,
setSelectedProductId: dataHook.setSelectedProductId,
productCreatedRef: dataHook.productCreatedRef,
featureCreatedRef: dataHook.featureCreatedRef,
handleRefetch: dataHook.handleRefetch,
refetchProducts: dataHook.refetchProducts,
refetchFeatures: dataHook.refetchFeatures,
setSheet,
setEditingState,
setIsButtonLoading: dataHook.setIsButtonLoading,
sharedActions,
});
return {
// Data
product: dataHook.product,
setProduct: dataHook.setProduct,
diff: dataHook.diff,
baseProduct: dataHook.baseProduct,
feature: dataHook.feature,
setFeature: dataHook.setFeature,
step: flowHook.step,
products: dataHook.products,
selectedProductId: dataHook.selectedProductId,
// Set handlers in store so components can access them directly
useEffect(() => {
const {
setHandleNext,
setHandleBack,
setHandlePlanSelect,
setOnCreatePlanSuccess,
setHandleDeletePlanSuccess,
setValidateStep,
} = useOnboardingStore.getState();
// UI State
sheet,
setSheet,
editingState,
setEditingState,
playgroundMode: flowHook.playgroundMode,
setPlaygroundMode: flowHook.setPlaygroundMode,
isQueryLoading: dataHook.isQueryLoading,
isButtonLoading: dataHook.isButtonLoading,
// Handlers
handleNext: stepActionsHook.handleNext,
handleBack: stepActionsHook.handleBack,
handlePlanSelect: stepActionsHook.handlePlanSelect,
onCreatePlanSuccess: stepActionsHook.onCreatePlanSuccess,
handleRefetch: dataHook.handleRefetch,
// Utils
validateStep: flowHook.validateStep,
navigate,
env,
};
setHandleNext(stepActionsHook.handleNext);
setHandleBack(stepActionsHook.handleBack);
setHandlePlanSelect(stepActionsHook.handlePlanSelect);
setOnCreatePlanSuccess(stepActionsHook.onCreatePlanSuccess);
setHandleDeletePlanSuccess(sharedActions.handleDeletePlanSuccess);
setValidateStep(flowHook.validateStep);
}, [
stepActionsHook.handleNext,
stepActionsHook.handleBack,
stepActionsHook.handlePlanSelect,
stepActionsHook.onCreatePlanSuccess,
sharedActions.handleDeletePlanSuccess,
flowHook.validateStep,
]);
};

View File

@@ -0,0 +1,53 @@
import { useEffect, useRef } from "react";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useOnboarding3QueryState } from "./useOnboarding3QueryState";
import { OnboardingStep } from "../utils/onboardingUtils";
/**
* Syncs product store with products list for onboarding
* Similar to useProductSync but uses products array instead of single product
*/
export const useOnboardingProductSync = () => {
const { products } = useProductsQuery();
const product = useProductStore((s) => s.product);
const setBaseProduct = useProductStore((s) => s.setBaseProduct);
const setProduct = useProductStore((s) => s.setProduct);
const { setQueryStates } = useOnboarding3QueryState();
const hasInitialized = useRef(false);
useEffect(() => {
// If no products exist and we've already initialized, redirect to step 1
if (hasInitialized.current && (!products || products.length === 0)) {
setQueryStates({ step: OnboardingStep.PlanDetails });
return;
}
if (!products || products.length === 0) return;
// Get the current product from the list (match by ID if product exists, otherwise first)
const currentProduct = product?.id
? products.find((p) => p.id === product.id) || products[0]
: products[0];
if (!currentProduct) return;
// Check if current product was deleted (current product ID not in products list)
const wasProductDeleted =
hasInitialized.current &&
product?.id &&
!products.find((p) => p.id === product.id);
// Always update baseProduct to reflect latest backend state
setBaseProduct(currentProduct);
// Update product in these cases:
// 1. Initial load (hasInitialized is false)
// 2. Current product was deleted (wasProductDeleted is true) - fallback to products[0]
if (!hasInitialized.current || wasProductDeleted) {
setProduct(currentProduct);
hasInitialized.current = true;
}
}, [products, product?.id, setBaseProduct, setProduct, setQueryStates]);
};

View File

@@ -1,74 +0,0 @@
import {
AppEnv,
BillingInterval,
type Feature,
type ProductItem,
type ProductV2,
} from "@autumn/shared";
import { useRef, useState } from "react";
import { getDefaultFeature } from "@/views/products/features/utils/defaultFeature";
export const useOnboardingState = () => {
// Base product state (managed by usePlanData in OnboardingContent)
const [baseProduct, setBaseProduct] = useState<ProductV2>({
id: "",
name: "",
items: [
{
price: "",
interval: BillingInterval.Month,
isBasePrice: true,
},
] as ProductItem[],
archived: false,
created_at: Date.now(),
is_add_on: false,
is_default: false,
version: 1,
group: "",
env: AppEnv.Sandbox,
internal_id: "",
});
const [baseFeature, setBaseFeature] = useState<Feature>(getDefaultFeature());
// Feature creation state
const [feature, setFeature] = useState(() => getDefaultFeature());
// Track whether product/feature have been created and their latest IDs
const productCreatedRef = useRef<{
created: boolean;
latestId: string | null;
}>({
created: false,
latestId: null,
});
const featureCreatedRef = useRef<{
created: boolean;
latestId: string | null;
}>({
created: false,
latestId: null,
});
// Loading state for Next button spinner
const [isButtonLoading, setIsButtonLoading] = useState(false);
return {
// Base product state (for usePlanData)
baseProduct,
setBaseProduct,
// Feature state
feature,
setFeature,
// Creation tracking refs
productCreatedRef,
featureCreatedRef,
// Loading state
isButtonLoading,
setIsButtonLoading,
};
};

View File

@@ -5,36 +5,37 @@ import {
isPriceItem,
productV2ToBasePrice,
} from "@autumn/shared";
import { useSteps } from "@/views/products/product/product-item/useSteps";
import { OnboardingStep } from "../utils/onboardingUtils";
import { useOnboarding3QueryState } from "./useOnboarding3QueryState";
export const useOnboardingSteps = () => {
const {
stepVal: step,
pushStep,
popStep,
} = useSteps({
initialStep: OnboardingStep.PlanDetails,
});
// Use query state instead of local state
const { queryStates } = useOnboarding3QueryState();
const step = queryStates.step;
// Step validation
const validateStep = (
currentStep: OnboardingStep,
product: ProductV2 | null,
product: ProductV2 | undefined,
feature: Feature | CreateFeature | null,
): boolean => {
switch (currentStep) {
case OnboardingStep.PlanDetails: {
// Return false if product is null or undefined
if (!product) return false;
// Basic validation for name and ID
const basicValid =
product?.name?.trim() !== "" && product?.id?.trim() !== "";
if (!basicValid) return false;
// Base price validation
// Base price validation - safely check if product has items
if (!product.items) return false;
const basePrice = productV2ToBasePrice({
product: product as unknown as ProductV2,
});
const hasBasePriceItem = product?.items?.some((item) =>
const hasBasePriceItem = product.items.some((item) =>
isPriceItem(item),
);
@@ -68,8 +69,6 @@ export const useOnboardingSteps = () => {
return {
step,
pushStep,
popStep,
validateStep,
};
};

View File

@@ -0,0 +1,99 @@
import type { CreateFeature, Feature, ProductV2 } from "@autumn/shared";
import { create } from "zustand";
import type { OnboardingStep } from "../utils/onboardingUtils";
// Types for the store
interface OnboardingState {
// Flow state (step is now managed by query state)
playgroundMode: "edit" | "preview";
hasCompletedOnboarding: boolean;
isOnboarding: boolean;
// UI state
isButtonLoading: boolean;
// Action handlers (set by initialization hooks)
handleNext: (() => void) | null;
handleBack: (() => void) | null;
handlePlanSelect: ((planId: string) => Promise<void>) | null;
onCreatePlanSuccess: ((newProduct: ProductV2) => Promise<void>) | null;
handleDeletePlanSuccess: (() => Promise<void>) | null;
validateStep:
| ((
step: OnboardingStep,
product: ProductV2 | undefined,
feature: Feature | CreateFeature | null,
) => boolean)
| null;
// Actions - Flow (step is managed by query state, not here)
setPlaygroundMode: (mode: "edit" | "preview") => void;
setHasCompletedOnboarding: (completed: boolean) => void;
setIsOnboarding: (isOnboarding: boolean) => void;
// Actions - UI
setIsButtonLoading: (loading: boolean) => void;
// Actions - Set handlers (called by initialization hooks)
setHandleNext: (handler: () => void) => void;
setHandleBack: (handler: () => void) => void;
setHandlePlanSelect: (handler: (planId: string) => Promise<void>) => void;
setOnCreatePlanSuccess: (
handler: (newProduct: ProductV2) => Promise<void>,
) => void;
setHandleDeletePlanSuccess: (handler: () => Promise<void>) => void;
setValidateStep: (
validator: (
step: OnboardingStep,
product: ProductV2 | undefined,
feature: Feature | CreateFeature | null,
) => boolean,
) => void;
// Complex actions
reset: () => void;
}
// Initial state factory
const createInitialState = () => ({
// Flow (step is now managed by query state)
playgroundMode: "edit" as const,
hasCompletedOnboarding: false,
isOnboarding: false,
// UI
isButtonLoading: false,
// Action handlers (initialized by hooks)
handleNext: null as (() => void) | null,
handleBack: null as (() => void) | null,
handlePlanSelect: null as (() => void) | null,
onCreatePlanSuccess: null as (() => void) | null,
handleDeletePlanSuccess: null as (() => void) | null,
validateStep: null as OnboardingState["validateStep"],
});
export const useOnboardingStore = create<OnboardingState>((set) => ({
...createInitialState(),
// Flow actions (step is managed by query state)
setPlaygroundMode: (playgroundMode) => set({ playgroundMode }),
setHasCompletedOnboarding: (hasCompletedOnboarding) =>
set({ hasCompletedOnboarding }),
setIsOnboarding: (isOnboarding) => set({ isOnboarding }),
// UI actions
setIsButtonLoading: (isButtonLoading) => set({ isButtonLoading }),
// Set action handlers (called by initialization hooks)
setHandleNext: (handleNext) => set({ handleNext }),
setHandleBack: (handleBack) => set({ handleBack }),
setHandlePlanSelect: (handlePlanSelect) => set({ handlePlanSelect }),
setOnCreatePlanSuccess: (onCreatePlanSuccess) => set({ onCreatePlanSuccess }),
setHandleDeletePlanSuccess: (handleDeletePlanSuccess) =>
set({ handleDeletePlanSuccess }),
setValidateStep: (validateStep) => set({ validateStep }),
// Complex actions
reset: () => set(createInitialState()),
}));

View File

@@ -201,8 +201,7 @@ export const handlePlanSelection = async (
setBaseProduct: (product: any) => void,
setProduct: (product: any) => void,
setSelectedProductId: (id: string) => void,
setSheet: (sheet: string) => void,
setEditingState: (state: any) => void,
setSheet: (params: { type: any; itemId?: string | null }) => void,
axiosInstance: AxiosInstance,
) => {
if (!planId || planId === selectedProductId) return;
@@ -215,8 +214,7 @@ export const handlePlanSelection = async (
setBaseProduct(productData);
setProduct(productData);
setSelectedProductId(planId);
setSheet("edit-plan");
setEditingState({ type: "plan", id: null });
setSheet({ type: "edit-plan" });
} catch (error) {
console.error("Failed to load selected plan:", error);
throw error;
@@ -229,8 +227,7 @@ export const handleCreatePlanSuccess = async (
axiosInstance: AxiosInstance,
setBaseProduct: (product: any) => void,
setSelectedProductId: (id: string) => void,
setSheet: (sheet: string) => void,
setEditingState: (state: any) => void,
setSheet: (params: { type: any; itemId?: string | null }) => void,
refetchProducts: () => Promise<void>,
) => {
// First refetch products to ensure the list is updated
@@ -247,18 +244,13 @@ export const handleCreatePlanSuccess = async (
setBaseProduct(productData);
// Finally set the UI state
setSheet("edit-plan");
setEditingState({ type: "plan", id: null });
setSheet({ type: "edit-plan" });
};
// Product creation helper
export const createProduct = async (
product: any,
axiosInstance: AxiosInstance,
productCreatedRef: MutableRefObject<{
created: boolean;
latestId: string | null;
}>,
) => {
try {
// const result = CreateProductSchema.safeParse({
@@ -273,15 +265,14 @@ export const createProduct = async (
// return null;
// }
let createdProduct: Awaited<
const createdProduct: Awaited<
ReturnType<typeof ProductService.createProduct>
>;
> = await ProductService.createProduct(axiosInstance, product);
createdProduct = await ProductService.createProduct(axiosInstance, product);
productCreatedRef.current = {
created: true,
latestId: createdProduct.id,
};
// productCreatedRef.current = {
// created: true,
// latestId: createdProduct.id,
// };
toast.success(`Product "${product?.name}" created successfully!`);
// if (!productCreatedRef.current.created) {
@@ -328,10 +319,6 @@ export const createProduct = async (
export const createFeature = async (
feature: CreateFeature,
axiosInstance: AxiosInstance,
featureCreatedRef: MutableRefObject<{
created: boolean;
latestId: string | null;
}>,
) => {
const result = CreateFeatureSchema.safeParse(feature);
if (result.error) {
@@ -342,42 +329,19 @@ export const createFeature = async (
}
try {
let newFeature: any;
// Create the feature
const { data } = await FeatureService.createFeature(axiosInstance, {
name: feature.name,
id: feature.id,
type: feature.type,
config: feature.config,
});
if (!featureCreatedRef.current.created) {
// First time creating the feature
const { data } = await FeatureService.createFeature(axiosInstance, {
name: feature.name,
id: feature.id,
type: feature.type,
config: feature.config,
});
newFeature = data;
featureCreatedRef.current = {
created: true,
latestId: data.id,
};
toast.success(`Feature "${feature.name}" created successfully!`);
} else {
// Feature already exists, update it (supports ID changes)
const { data } = await FeatureService.updateFeature(
axiosInstance,
featureCreatedRef.current.latestId as string,
{
name: feature.name,
id: feature.id,
type: feature.type,
config: feature.config,
},
);
newFeature = data;
featureCreatedRef.current.latestId = data.id;
toast.success(`Feature "${feature.name}" updated successfully!`);
}
toast.success(`Feature "${feature.name}" created successfully!`);
if (!newFeature?.id) return null;
if (!data?.id) return null;
return newFeature;
return data;
} catch (error: unknown) {
toast.error(
getBackendErr(error as AxiosError, "Failed to create/update feature"),
@@ -516,3 +480,21 @@ export const syncProductItemsWithFeature = (
items: updatedItems,
};
};
// Helper to get baseProduct from products array by ID
export const getBaseProduct = (
products: ProductV2[] | undefined,
baseProductId: string | null,
): ProductV2 | null => {
if (!products || !baseProductId) return null;
return products.find((p) => p.id === baseProductId) || null;
};
// Helper to get baseFeature from features array by ID
export const getBaseFeature = (
features: (Feature | CreateFeature)[] | undefined,
baseFeatureId: string | null,
): Feature | CreateFeature | null => {
if (!features || !baseFeatureId) return null;
return features.find((f) => f.id === baseFeatureId) || null;
};

View File

@@ -1,57 +1,42 @@
import { type ProductItem, productV2ToFeatureItems } from "@autumn/shared";
import { useState } from "react";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { getItemId } from "@/utils/product/productItemUtils";
import { useHasChanges } from "@/hooks/stores/useProductStore";
import { useProductSync } from "@/hooks/stores/useProductSync";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import LoadingScreen from "@/views/general/LoadingScreen";
import { useProductChangedAlert } from "../product/hooks/useProductChangedAlert";
import { useProductQuery } from "../product/hooks/useProductQuery";
import { ProductContext, useProductContext } from "../product/ProductContext";
import { ProductItemContext } from "../product/product-item/ProductItemContext";
import { EditPlanFeatureSheet } from "./components/EditPlanFeatureSheet/EditPlanFeatureSheet";
import { ProductContext } from "../product/ProductContext";
import { EditPlanHeader } from "./components/EditPlanHeader";
import { EditPlanSheet } from "./components/EditPlanSheet";
import { ManagePlan } from "./components/ManagePlan";
import { NewFeatureSheet } from "./components/new-feature/NewFeatureSheet";
import { SaveChangesBar } from "./components/SaveChangesBar";
import { SelectFeatureSheet } from "./components/SelectFeatureSheet";
import { usePlanData } from "./hooks/usePlanData";
import { ProductSheets } from "./ProductSheets";
import ConfirmNewVersionDialog from "./versioning/ConfirmNewVersionDialog";
type Sheets = "edit-plan" | "edit-feature" | "new-feature" | "select-feature";
export default function PlanEditorView() {
const {
product: originalProduct,
isLoading: productLoading,
refetch,
} = useProductQuery();
const { isLoading: featuresLoading } = useFeaturesQuery();
const { product, setProduct, diff } = usePlanData({
originalProduct,
});
// Sync store with backend data
useProductSync({ product: originalProduct });
const hasChanges = useHasChanges();
const { modal } = useProductChangedAlert({ hasChanges });
const { modal } = useProductChangedAlert({ hasChanges: diff.hasChanges });
const [showNewVersionDialog, setShowNewVersionDialog] = useState(false);
const setSheet = useSheetStore((s) => s.setSheet);
const [sheet, setSheet] = useState<Sheets>("edit-plan");
const [editingState, setEditingState] = useState<{
type: "plan" | "feature" | null;
id: string | null;
}>({ type: "plan", id: null });
if (!product || featuresLoading || productLoading) return <LoadingScreen />;
if (featuresLoading || productLoading) return <LoadingScreen />;
return (
<ProductContext.Provider
value={{
setShowNewVersionDialog,
product,
setProduct,
diff,
setSheet,
editingState,
setEditingState,
refetch,
}}
>
@@ -59,9 +44,8 @@ export default function PlanEditorView() {
open={showNewVersionDialog}
setOpen={setShowNewVersionDialog}
onVersionCreated={() => {
// Reset editing state when new version is created
setEditingState({ type: null, id: null });
setSheet("edit-plan");
// Reset sheet when new version is created
setSheet({ type: "edit-plan" });
}}
/>
<div className="flex w-full h-full overflow-y-auto bg-[#eee]">
@@ -71,75 +55,9 @@ export default function PlanEditorView() {
<SaveChangesBar />
</div>
<PlanSheets sheet={sheet} />
<ProductSheets />
</div>
{modal}
</ProductContext.Provider>
);
}
export const PlanSheets = ({ sheet }: { sheet: Sheets }) => {
const { product, setProduct, editingState } = useProductContext();
const featureItems = productV2ToFeatureItems({ items: product?.items });
const isCurrentItem = (item: ProductItem, index: number) => {
const itemId = getItemId({ item, itemIndex: index });
return editingState.id === itemId;
};
const currentItem = featureItems.find(isCurrentItem);
const setCurrentItem = (updatedItem: ProductItem) => {
if (!product || !product.items) return;
const filteredItems = productV2ToFeatureItems({
items: product.items,
withBasePrice: true,
});
const currentItemIndex = filteredItems.findIndex(isCurrentItem);
if (currentItemIndex === -1) return;
const updatedItems = [...filteredItems];
updatedItems[currentItemIndex] = updatedItem;
setProduct({ ...product, items: updatedItems });
};
// Don't render on small screens
const renderSheet = () => {
switch (sheet) {
case "edit-plan":
return <EditPlanSheet />;
case "edit-feature":
return (
<ProductItemContext.Provider
value={{
item: currentItem ?? null,
setItem: setCurrentItem,
selectedIndex: 0,
showCreateFeature: false,
setShowCreateFeature: () => {},
isUpdate: false,
handleUpdateProductItem: async () => null,
}}
>
<EditPlanFeatureSheet />
</ProductItemContext.Provider>
);
case "new-feature":
return <NewFeatureSheet />;
case "select-feature":
return <SelectFeatureSheet />;
default:
return <EditPlanSheet />;
}
};
return (
<div className="w-full min-w-xs max-w-md bg-card z-50 border-l shadow-sm flex flex-col overflow-y-auto h-full">
{renderSheet()}
</div>
);
};

View File

@@ -0,0 +1,79 @@
import { type ProductItem, productV2ToFeatureItems } from "@autumn/shared";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { getItemId } from "@/utils/product/productItemUtils";
import { ProductItemContext } from "../product/product-item/ProductItemContext";
import { EditPlanFeatureSheet } from "./components/EditPlanFeatureSheet/EditPlanFeatureSheet";
import { EditPlanSheet } from "./components/EditPlanSheet";
import { NewFeatureSheet } from "./components/new-feature/NewFeatureSheet";
import { SelectFeatureSheet } from "./components/SelectFeatureSheet";
export const ProductSheets = () => {
const product = useProductStore((s) => s.product);
const setProduct = useProductStore((s) => s.setProduct);
const sheetType = useSheetStore((s) => s.type);
const itemId = useSheetStore((s) => s.itemId);
const featureItems = productV2ToFeatureItems({ items: product.items });
const isCurrentItem = (item: ProductItem, index: number) => {
const currentItemId = getItemId({ item, itemIndex: index });
return itemId === currentItemId;
};
const currentItem = featureItems.find(isCurrentItem);
const setCurrentItem = (updatedItem: ProductItem) => {
if (!product || !product.items) return;
const filteredItems = productV2ToFeatureItems({
items: product.items,
withBasePrice: true,
});
const currentItemIndex = filteredItems.findIndex(isCurrentItem);
if (currentItemIndex === -1) return;
const updatedItems = [...filteredItems];
updatedItems[currentItemIndex] = updatedItem;
setProduct({ ...product, items: updatedItems });
};
// Don't render on small screens
const renderSheet = () => {
switch (sheetType) {
case "edit-plan":
return <EditPlanSheet />;
case "edit-feature":
return (
<ProductItemContext.Provider
value={{
item: currentItem ?? null,
setItem: setCurrentItem,
selectedIndex: 0,
showCreateFeature: false,
setShowCreateFeature: () => {},
isUpdate: false,
handleUpdateProductItem: async () => null,
}}
>
<EditPlanFeatureSheet />
</ProductItemContext.Provider>
);
case "new-feature":
return <NewFeatureSheet />;
case "select-feature":
return <SelectFeatureSheet />;
default:
return <EditPlanSheet />;
}
};
return (
<div className="w-full min-w-xs max-w-md bg-card z-50 border-l shadow-sm flex flex-col overflow-y-auto h-full">
{renderSheet()}
</div>
);
};

View File

@@ -19,21 +19,23 @@ import {
} from "@/components/v2/selects/Select";
import { useGeneralQuery } from "@/hooks/queries/useGeneralQuery";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { ProductService } from "@/services/products/ProductService";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { getBackendErr } from "@/utils/genUtils";
import { useProductQuery } from "../../product/hooks/useProductQuery";
import { useProductContext } from "../../product/ProductContext";
export const DeletePlanDialog = ({
open,
setOpen,
onDeleteSuccess,
}: {
open: boolean;
setOpen: (open: boolean) => void;
onDeleteSuccess?: () => Promise<void>;
}) => {
const axiosInstance = useAxiosInstance();
const { product } = useProductContext();
const product = useProductStore((s) => s.product);
const [loading, setLoading] = useState(false);
const [deleteAllVersions, setDeleteAllVersions] = useState(false);
const { refetch: refetchProducts } = useProductsQuery();
@@ -57,6 +59,11 @@ export const DeletePlanDialog = ({
await refetchProducts();
setOpen(false);
toast.success("Plan deleted successfully");
// Call onDeleteSuccess callback if provided (for onboarding)
if (onDeleteSuccess) {
await onDeleteSuccess();
}
} catch (error: unknown) {
toast.error(getBackendErr(error as AxiosError, "Error deleting plan"));
} finally {

View File

@@ -1,12 +1,15 @@
import { SheetHeader } from "@/components/v2/sheets/InlineSheet";
import { useProductContext } from "../../product/ProductContext";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { AdditionalOptions } from "./edit-plan-details/AdditionalOptions";
import { BasePriceSection } from "./edit-plan-details/BasePriceSection";
import { FreeTrialSection } from "./edit-plan-details/FreeTrialSection";
import { MainDetailsSection } from "./edit-plan-details/MainDetailsSection";
export function EditPlanSheet({ isOnboarding }: { isOnboarding?: boolean }) {
const { product } = useProductContext();
const product = useProductStore((s) => s.product);
if (!product) return null;
return (
<>
{!isOnboarding && (

View File

@@ -1,7 +1,7 @@
import { PlusIcon } from "@phosphor-icons/react";
import { Button } from "@/components/v2/buttons/Button";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductContext } from "@/views/products/product/ProductContext";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
interface AddFeatureRowProps {
disabled?: boolean;
@@ -10,17 +10,15 @@ interface AddFeatureRowProps {
export const AddFeatureRow = ({ disabled }: AddFeatureRowProps) => {
const { features } = useFeaturesQuery();
const { setSheet, setEditingState } = useProductContext();
const setSheet = useSheetStore((s) => s.setSheet);
const handleAddFeatureClick = () => {
if (features.length === 0) {
// No features exist, go directly to create flow
setEditingState({ type: "feature", id: "new" });
setSheet("new-feature");
setSheet({ type: "new-feature", itemId: "new" });
} else {
// Features exist, open select sheet
setEditingState({ type: "feature", id: "select" });
setSheet("select-feature");
setSheet({ type: "select-feature", itemId: "select" });
}
};

View File

@@ -9,7 +9,6 @@ import {
DialogTitle,
} from "@/components/v2/dialogs/Dialog";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductContext } from "@/views/products/product/ProductContext";
export const DeleteFeatureRowDialog = ({
open,
@@ -23,7 +22,6 @@ export const DeleteFeatureRowDialog = ({
onDelete: (item: ProductItem) => void;
}) => {
const { features } = useFeaturesQuery();
const { product } = useProductContext();
const featureName = features.find((f) => f.id === item.feature_id)?.name;
return (

View File

@@ -3,16 +3,17 @@ import { CrosshairSimpleIcon } from "@phosphor-icons/react";
import { PlanTypeBadges } from "@/components/v2/badges/PlanTypeBadges";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { CardHeader } from "@/components/v2/cards/Card";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useIsEditingPlan, useSheetStore } from "@/hooks/stores/useSheetStore";
import { keyToTitle } from "@/utils/formatUtils/formatTextUtils";
import { useProductContext } from "@/views/products/product/ProductContext";
import { PlanCardToolbar } from "./PlanCardToolbar";
export const PlanCardHeader = () => {
const { product, setEditingState, setSheet, editingState } =
useProductContext();
const product = useProductStore((s) => s.product);
const setSheet = useSheetStore((s) => s.setSheet);
const isPlanBeingEdited = useIsEditingPlan();
const productV3 = mapToProductV3({ product });
const isPlanBeingEdited = editingState.type === "plan";
return (
<CardHeader>
@@ -25,8 +26,7 @@ export const PlanCardHeader = () => {
</div>
<PlanCardToolbar
onEdit={() => {
setEditingState({ type: "plan", id: product.id });
setSheet("edit-plan");
setSheet({ type: "edit-plan", itemId: product.id });
}}
onDelete={() => console.log("Delete plan:", product.id)}
editDisabled={isPlanBeingEdited}
@@ -43,8 +43,7 @@ export const PlanCardHeader = () => {
variant="secondary"
icon={<CrosshairSimpleIcon />}
onClick={() => {
setEditingState({ type: "plan", id: product.id });
setSheet("edit-plan");
setSheet({ type: "edit-plan", itemId: product.id });
}}
disabled={isPlanBeingEdited}
className="mt-2 !opacity-100"

View File

@@ -3,13 +3,14 @@ import { useState } from "react";
import { Button } from "@/components/v2/buttons/Button";
import { CopyButton } from "@/components/v2/buttons/CopyButton";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useIsEditingPlan } from "@/hooks/stores/useSheetStore";
import { cn } from "@/lib/utils";
import { useProductContext } from "@/views/products/product/ProductContext";
import { DeletePlanDialog } from "../DeletePlanDialog";
interface PlanCardToolbarProps {
onEdit?: () => void;
onDelete?: () => void;
onDeleteSuccess?: () => Promise<void>;
editDisabled?: boolean;
deleteDisabled?: boolean;
deleteTooltip?: string;
@@ -17,13 +18,14 @@ interface PlanCardToolbarProps {
export const PlanCardToolbar = ({
onEdit,
onDeleteSuccess,
editDisabled,
deleteDisabled,
deleteTooltip,
}: PlanCardToolbarProps) => {
const { editingState, product } = useProductContext();
const product = useProductStore((s) => s.product);
const [deleteOpen, setDeleteOpen] = useState(false);
const isEditingPlan = editingState.type === "plan";
const isEditingPlan = useIsEditingPlan();
return (
<>
@@ -34,7 +36,11 @@ export const PlanCardToolbar = ({
size="sm"
/>
)}
<DeletePlanDialog open={deleteOpen} setOpen={setDeleteOpen} />
<DeletePlanDialog
open={deleteOpen}
setOpen={setDeleteOpen}
onDeleteSuccess={onDeleteSuccess}
/>
<div className="flex flex-row items-center gap-1">
<IconButton
icon={<PencilSimpleIcon />}

View File

@@ -1,6 +1,10 @@
import { type ProductItem, productV2ToFeatureItems } from "@autumn/shared";
import { useProductStore } from "@/hooks/stores/useProductStore";
import {
useIsCreatingFeature,
useSheetStore,
} from "@/hooks/stores/useSheetStore";
import { getItemId } from "@/utils/product/productItemUtils";
import { useProductContext } from "@/views/products/product/ProductContext";
import { AddFeatureRow } from "./AddFeatureRow";
import { PlanFeatureRow } from "./PlanFeatureRow";
@@ -9,13 +13,19 @@ export const PlanFeatureList = ({
}: {
allowAddFeature?: boolean;
}) => {
const { product, setProduct, setSheet, editingState, setEditingState } =
useProductContext();
const product = useProductStore((s) => s.product);
const setProduct = useProductStore((s) => s.setProduct);
const setSheet = useSheetStore((s) => s.setSheet);
const itemId = useSheetStore((s) => s.itemId);
const isCreatingFeature = useIsCreatingFeature();
const filteredItems = productV2ToFeatureItems({ items: product?.items });
// Guard against undefined product
if (!product) return null;
const filteredItems = productV2ToFeatureItems({ items: product.items });
const handleDelete = (item: ProductItem) => {
if (!product?.items) return;
if (!product.items) return;
// Remove the item from the product
const newItems = product.items.filter((i: ProductItem) => i !== item);
@@ -24,16 +34,14 @@ export const PlanFeatureList = ({
// Close editing sidebar if this item was being edited
const itemIndex = product.items.findIndex((i: ProductItem) => i === item);
const itemId = getItemId({ item, itemIndex });
if (editingState.id === itemId) {
setEditingState({ type: "edit-plan", id: null });
setSheet("edit-plan");
const currentItemId = getItemId({ item, itemIndex });
if (itemId === currentItemId) {
setSheet({ type: "edit-plan" });
}
};
const handleAddFeature = () => {
setEditingState({ type: "feature", id: "new" });
setSheet("edit-feature");
setSheet({ type: "new-feature", itemId: "new" });
};
if (filteredItems.length === 0) {
@@ -42,9 +50,7 @@ export const PlanFeatureList = ({
<div className="space-y-1">
<AddFeatureRow
onClick={handleAddFeature}
disabled={
editingState.type === "feature" && editingState.id === "new"
}
disabled={isCreatingFeature}
/>
</div>
</div>
@@ -67,9 +73,7 @@ export const PlanFeatureList = ({
{allowAddFeature && (
<AddFeatureRow
onClick={handleAddFeature}
disabled={
editingState.type === "feature" && editingState.id === "new"
}
disabled={isCreatingFeature}
/>
)}
</div>

View File

@@ -8,10 +8,12 @@ import { CopyButton } from "@/components/v2/buttons/CopyButton";
import { IconButton } from "@/components/v2/buttons/IconButton";
import { useOrg } from "@/hooks/common/useOrg";
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { cn } from "@/lib/utils";
import { getItemId } from "@/utils/product/productItemUtils";
import { useProductContext } from "@/views/products/product/ProductContext";
import { useProductItemContext } from "@/views/products/product/product-item/ProductItemContext";
import { useOnboardingStore } from "@/views/onboarding3/store/useOnboardingStore";
import { PlanFeatureIcon } from "./PlanFeatureIcon";
// Custom dot component with bigger height but smaller width
@@ -33,13 +35,16 @@ export const PlanFeatureRow = ({
const { org } = useOrg();
const { features } = useFeaturesQuery();
const { setItem } = useProductItemContext();
const { product, setProduct, editingState, setEditingState, setSheet } =
useProductContext();
const product = useProductStore((s) => s.product);
const setProduct = useProductStore((s) => s.setProduct);
const itemId = useSheetStore((s) => s.itemId);
const setSheet = useSheetStore((s) => s.setSheet);
const isOnboarding = useOnboardingStore((s) => s.isOnboarding);
const [isPressed, setIsPressed] = useState(false);
// Always use the current item from product.items for real-time updates
const featureItems = productV2ToFeatureItems({ items: product?.items });
const featureItems = productV2ToFeatureItems({ items: product.items });
const item = featureItems[index] || itemProp;
const display = getProductItemDisplay({
@@ -50,8 +55,8 @@ export const PlanFeatureRow = ({
amountFormatOptions: { currencyDisplay: "narrowSymbol" },
});
const itemId = getItemId({ item, itemIndex: index });
const isSelected = itemId === editingState.id;
const currentItemId = getItemId({ item, itemIndex: index });
const isSelected = itemId === currentItemId;
// Clear pressed state when this item is no longer selected
useEffect(() => {
@@ -60,14 +65,14 @@ export const PlanFeatureRow = ({
// Also clear pressed state whenever editing state changes (catches hotkey navigation)
useEffect(() => {
if (editingState.id !== itemId) setIsPressed(false);
}, [editingState.id, itemId]);
if (itemId !== currentItemId) setIsPressed(false);
}, [itemId, currentItemId]);
const handleRowClicked = () => {
const itemId = getItemId({ item, itemIndex: index });
if (isOnboarding) return;
const currentItemId = getItemId({ item, itemIndex: index });
setItem(item);
setEditingState({ type: "feature", id: itemId });
setSheet("edit-feature");
setSheet({ type: "edit-feature", itemId: currentItemId });
};
const handleDeleteRow = () => {
@@ -82,8 +87,7 @@ export const PlanFeatureRow = ({
setProduct({ ...product, items: newItems });
if (isSelected) {
setEditingState({ type: "plan", id: null });
setSheet("edit-plan");
setSheet({ type: "edit-plan" });
}
};
@@ -97,22 +101,32 @@ export const PlanFeatureRow = ({
"flex w-full group !h-9 group/row input-base input-shadow-tiny select-bg select-none",
// To prevent flickering when clicking inner buttons
!isSelected &&
!isOnboarding &&
!isSelected &&
"hover:!bg-hover-primary focus-visible:!bg-hover-primary focus-visible:!border-primary active:!bg-active-primary",
isSelected && "!bg-hover-primary !border-primary",
!isOnboarding && isSelected && "!bg-hover-primary !border-primary",
isOnboarding && "pointer-events-none cursor-default",
// // Custom pressed state that we can control
// "data-[pressed=true]:!bg-active-primary data-[pressed=true]:border-primary focus:outline-none active:!bg-transparent active:!border-transparent",
)}
onMouseDown={(e) => {
if (isOnboarding) return;
// Only set pressed if we're not clicking on a button
if (!(e.target as Element).closest("button")) {
setIsPressed(true);
}
}}
onMouseUp={() => setIsPressed(false)}
onMouseLeave={() => setIsPressed(false)}
onMouseUp={() => {
if (isOnboarding) return;
setIsPressed(false);
}}
onMouseLeave={() => {
if (isOnboarding) return;
setIsPressed(false);
}}
onClick={handleRowClicked}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {

View File

@@ -1,29 +1,31 @@
import { type ProductItem, productV2ToFeatureItems } from "@autumn/shared";
import { useCallback, useEffect, useState } from "react";
import { useHotkeys } from "react-hotkeys-hook";
import { useProductStore } from "@/hooks/stores/useProductStore";
import { useSheetStore } from "@/hooks/stores/useSheetStore";
import { getItemId } from "@/utils/product/productItemUtils";
import { useProductContext } from "@/views/products/product/ProductContext";
export const useFeatureNavigation = () => {
const { product, editingState, setEditingState, setSheet } =
useProductContext();
const product = useProductStore((s) => s.product);
const itemId = useSheetStore((s) => s.itemId);
const setSheet = useSheetStore((s) => s.setSheet);
const [selectedIndex, setSelectedIndex] = useState<number>(0);
// Get filtered items (non-price items)
const filteredItems = productV2ToFeatureItems({ items: product?.items });
const filteredItems = productV2ToFeatureItems({ items: product.items });
// const filteredItems = useMemo(() => {
// return productV2ToFeatureItems({ items: product?.items });
// }, [product?.items]);
// Update selected index when editing state changes or items change
useEffect(() => {
if (editingState.id && filteredItems.length > 0) {
if (itemId && filteredItems.length > 0) {
const currentIndex = filteredItems.findIndex((item: ProductItem) => {
// Find the actual index in the product items array
const actualIndex =
product?.items?.findIndex((i: ProductItem) => i === item) ?? 0;
const itemId = getItemId({ item, itemIndex: actualIndex });
return itemId === editingState.id;
const currentItemId = getItemId({ item, itemIndex: actualIndex });
return currentItemId === itemId;
});
if (currentIndex !== -1) {
setSelectedIndex(currentIndex);
@@ -32,7 +34,7 @@ export const useFeatureNavigation = () => {
// Reset selection when no items
setSelectedIndex(0);
}
}, [editingState.id, filteredItems, product?.items]);
}, [itemId, filteredItems, product?.items]);
// Handle when selected index becomes out of bounds (e.g., item deleted)
useEffect(() => {
@@ -59,11 +61,10 @@ export const useFeatureNavigation = () => {
product?.items?.findIndex((i: ProductItem) => i === item) ??
clampedIndex;
const itemId = getItemId({ item, itemIndex: actualIndex });
setEditingState({ type: "feature", id: itemId });
setSheet("edit-feature");
setSheet({ type: "edit-feature", itemId });
}
},
[filteredItems, setEditingState, setSheet, product?.items],
[filteredItems, setSheet, product?.items],
);
const navigateUp = useCallback(() => {
@@ -84,9 +85,8 @@ export const useFeatureNavigation = () => {
const editPlan = useCallback(() => {
if (!product) return;
setEditingState({ type: "plan", id: product.id });
setSheet("edit-plan");
}, [product, setEditingState, setSheet]);
setSheet({ type: "edit-plan", itemId: product.id });
}, [product, setSheet]);
const addNewFeature = useCallback(() => {
// Trigger the add feature popover by clicking the button

View File

@@ -1,9 +1,14 @@
import type { FrontendProduct } from "@autumn/shared";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/v2/buttons/Button";
import { ShortcutButton } from "@/components/v2/buttons/ShortcutButton";
import { useProductChangedAlert } from "@/components/v2/hooks";
import { useProductsQuery } from "@/hooks/queries/useProductsQuery";
import {
useHasChanges,
useProductStore,
useWillVersion,
} from "@/hooks/stores/useProductStore";
import { useAxiosInstance } from "@/services/useAxiosInstance";
import { useProductCountsQuery } from "../../product/hooks/queries/useProductCountsQuery";
import { useProductQuery } from "../../product/hooks/useProductQuery";
@@ -12,35 +17,28 @@ import { updateProduct } from "../../product/utils/updateProduct";
interface SaveChangesBarProps {
isOnboarding?: boolean;
originalProduct?: FrontendProduct;
setOriginalProduct?: (product: FrontendProduct) => void;
}
export const SaveChangesBar = ({
isOnboarding = false,
originalProduct: onboardingOriginalProduct,
setOriginalProduct: setOnboardingOriginalProduct,
}: SaveChangesBarProps) => {
const axiosInstance = useAxiosInstance();
const {
diff,
setProduct,
product,
setShowNewVersionDialog,
refetch: contextRefetch,
} = useProductContext();
const { setShowNewVersionDialog } = useProductContext();
// Get product state from store
const product = useProductStore((s) => s.product);
const setProduct = useProductStore((s) => s.setProduct);
const hasChanges = useHasChanges();
const willVersion = useWillVersion();
const [saving, setSaving] = useState(false);
const { refetch } = useProductsQuery();
const { counts, isLoading } = useProductCountsQuery();
const { refetch: queryRefetch, product: queryOriginalProduct } =
useProductQuery();
const originalProduct = isOnboarding
? onboardingOriginalProduct
: queryOriginalProduct;
const { refetch: queryRefetch } = useProductQuery();
const { modal } = useProductChangedAlert({
hasChanges: diff.hasChanges,
hasChanges,
disabled: isOnboarding, // Disable navigation blocking in onboarding mode
});
@@ -50,7 +48,7 @@ export const SaveChangesBar = ({
return;
}
if (!isOnboarding && counts?.all > 0 && diff.willVersion) {
if (!isOnboarding && counts?.all > 0 && willVersion) {
setShowNewVersionDialog(true);
return;
}
@@ -58,34 +56,29 @@ export const SaveChangesBar = ({
setSaving(true);
await updateProduct({
axiosInstance,
productId: product.id,
product,
onSuccess: async () => {
if (isOnboarding) {
// Use the unified refetch from context (hybrid approach)
if (contextRefetch) {
await contextRefetch();
} else if (setOnboardingOriginalProduct && product) {
// Fallback: manual product update (should not be needed with hybrid approach)
const response = await axiosInstance.get(
`/products/${product.id}/data2`,
);
setOnboardingOriginalProduct(response.data.product);
}
await refetch();
} else {
// Normal PEV refetch
await queryRefetch();
}
},
});
toast.success("Changes saved successfully");
setSaving(false);
};
const handleDiscardClicked = () => {
setProduct(originalProduct as FrontendProduct);
const baseProduct = useProductStore.getState().baseProduct;
if (baseProduct) {
setProduct(baseProduct);
}
};
if (!diff.hasChanges) return null;
if (!hasChanges) return null;
return (
<>

Some files were not shown because too many files have changed in this diff Show More