wip
This commit is contained in:
35
AGENTS.md
Normal file
35
AGENTS.md
Normal 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)
|
||||
|
||||
43
example/.gitignore
vendored
43
example/.gitignore
vendored
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
7021
example/package-lock.json
generated
7021
example/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
14
server/example.ts
Normal 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"]
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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({
|
||||
|
||||
29
shared/api/errors/classes/featureErrClasses.ts
Normal file
29
shared/api/errors/classes/featureErrClasses.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
7
shared/api/errors/codes/featureErrCodes.ts
Normal file
7
shared/api/errors/codes/featureErrCodes.ts
Normal 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];
|
||||
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
88
shared/scripts/watch.js
Normal 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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
105
shared/utils/featureUtils/apiFeatureToDbFeature.ts
Normal file
105
shared/utils/featureUtils/apiFeatureToDbFeature.ts
Normal 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,
|
||||
// });
|
||||
// };
|
||||
152
shared/utils/featureUtils/constructFeatureUtils.ts
Normal file
152
shared/utils/featureUtils/constructFeatureUtils.ts
Normal 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;
|
||||
// };
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
// }
|
||||
// };
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
31
vite/src/hooks/stores/useFeatureStore.ts
Normal file
31
vite/src/hooks/stores/useFeatureStore.ts
Normal 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),
|
||||
}));
|
||||
86
vite/src/hooks/stores/useProductStore.ts
Normal file
86
vite/src/hooks/stores/useProductStore.ts
Normal 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]);
|
||||
};
|
||||
38
vite/src/hooks/stores/useProductSync.ts
Normal file
38
vite/src/hooks/stores/useProductSync.ts
Normal 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]);
|
||||
};
|
||||
54
vite/src/hooks/stores/useSheetStore.ts
Normal file
54
vite/src/hooks/stores/useSheetStore.ts
Normal 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");
|
||||
@@ -79,6 +79,7 @@
|
||||
&:focus,
|
||||
&[data-state="open"] {
|
||||
background-color: var(--color-active-primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
62
vite/src/views/onboarding3/hooks/useInitFeatureItem.tsx
Normal file
62
vite/src/views/onboarding3/hooks/useInitFeatureItem.tsx
Normal 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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
};
|
||||
|
||||
53
vite/src/views/onboarding3/hooks/useOnboardingProductSync.ts
Normal file
53
vite/src/views/onboarding3/hooks/useOnboardingProductSync.ts
Normal 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]);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
99
vite/src/views/onboarding3/store/useOnboardingStore.ts
Normal file
99
vite/src/views/onboarding3/store/useOnboardingStore.ts
Normal 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()),
|
||||
}));
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
79
vite/src/views/products/plan/ProductSheets.tsx
Normal file
79
vite/src/views/products/plan/ProductSheets.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 === " ") {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user