fix:修复类型提示错误
This commit is contained in:
@@ -1,40 +1,52 @@
|
|||||||
import { Link } from "react-router-dom";
|
import React from 'react';
|
||||||
import { svgs } from "./svgs";
|
import { Link } from 'react-router-dom';
|
||||||
|
import { svgs } from './svgs';
|
||||||
|
|
||||||
const Button = ({
|
interface ButtonProps {
|
||||||
className,
|
className?: string;
|
||||||
href,
|
href?: string;
|
||||||
onClick,
|
onClick?: () => void;
|
||||||
children,
|
children?: React.ReactNode;
|
||||||
px,
|
px?: string;
|
||||||
white,
|
white?: boolean;
|
||||||
|
isPrimary?: boolean;
|
||||||
|
isSecondary?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button: React.FC<ButtonProps> = ({
|
||||||
|
className,
|
||||||
|
href,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
px,
|
||||||
|
white,
|
||||||
}) => {
|
}) => {
|
||||||
const classes = `button relative inline-flex items-center justify-center h-11 ${
|
const classes = `button relative inline-flex items-center justify-center h-11 ${
|
||||||
px || "px-7"
|
px || 'px-7'
|
||||||
} ${white ? "text-n-8" : "text-n-1"} transition-colors hover:text-color-1 ${
|
} ${white ? 'text-n-8' : 'text-n-1'} transition-colors hover:text-color-1 ${
|
||||||
className || ""
|
className || ''
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const spanClasses = `relative z-10`;
|
const spanClasses = `relative z-10`;
|
||||||
|
|
||||||
return href ? (
|
return href ? (
|
||||||
href.startsWith("mailto:") ? (
|
href.startsWith('mailto:') ? (
|
||||||
<a href={href} className={classes}>
|
<a href={href} className={classes}>
|
||||||
<span className={spanClasses}>{children}</span>
|
<span className={spanClasses}>{children}</span>
|
||||||
{svgs(white)}
|
{svgs(white)}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
|
||||||
<Link href={href} className={classes}>
|
|
||||||
<span className={spanClasses}>{children}</span>
|
|
||||||
{svgs(white)}
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
<button className={classes} onClick={onClick}>
|
<Link to={href} className={classes}>
|
||||||
<span className={spanClasses}>{children}</span>
|
<span className={spanClasses}>{children}</span>
|
||||||
{svgs(white)}
|
{svgs(white)}
|
||||||
</button>
|
</Link>
|
||||||
);
|
)
|
||||||
|
) : (
|
||||||
|
<button className={classes} onClick={onClick}>
|
||||||
|
<span className={spanClasses}>{children}</span>
|
||||||
|
{svgs(white)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Button;
|
export default Button;
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Link, { LinkProps } from "next/link";
|
|
||||||
|
|
||||||
type CommonProps = {
|
|
||||||
className?: string;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
isPrimary?: boolean;
|
|
||||||
isSecondary?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ButtonAsButton = {
|
|
||||||
as?: "button";
|
|
||||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
|
|
||||||
|
|
||||||
type ButtonAsAnchor = {
|
|
||||||
as: "a";
|
|
||||||
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
|
|
||||||
|
|
||||||
type ButtonAsLink = {
|
|
||||||
as: "link";
|
|
||||||
} & LinkProps;
|
|
||||||
|
|
||||||
type ButtonProps = CommonProps &
|
|
||||||
(ButtonAsButton | ButtonAsAnchor | ButtonAsLink);
|
|
||||||
|
|
||||||
const Button: React.FC<ButtonProps> = ({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
isPrimary,
|
|
||||||
isSecondary,
|
|
||||||
as = "button",
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const isLink = as === "link";
|
|
||||||
const Component: React.ElementType = isLink ? Link : as;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Component
|
|
||||||
className={`relative inline-flex justify-center items-center h-10 px-3.5 rounded-lg text-title-5 cursor-pointer transition-all ${
|
|
||||||
isPrimary ? "bg-white text-black hover:bg-white/90" : ""
|
|
||||||
} ${
|
|
||||||
isSecondary
|
|
||||||
? "shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset] text-white after:absolute after:inset-0 after:border after:border-line after:rounded-lg after:pointer-events-none after:transition-colors hover:after:border-white"
|
|
||||||
: ""
|
|
||||||
} ${className || ""}`}
|
|
||||||
{...(isLink ? (props as LinkProps) : props)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Component>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Button;
|
|
||||||
@@ -1,20 +1,25 @@
|
|||||||
import Image from "../Image";
|
import React from 'react';
|
||||||
|
import Image from '../Image';
|
||||||
|
|
||||||
const Generating = ({ className }) => (
|
interface GeneratingProps {
|
||||||
<div
|
className?: string;
|
||||||
className={`flex items-center h-[3.375rem] px-6 bg-n-8/80 rounded-[1.6875rem] ${
|
}
|
||||||
className || ""
|
|
||||||
} text-base`}
|
const Generating: React.FC<GeneratingProps> = ({ className }) => (
|
||||||
>
|
<div
|
||||||
<Image
|
className={`flex items-center h-[3.375rem] px-6 bg-n-8/80 rounded-[1.6875rem] ${
|
||||||
className="w-5 h-5 mr-4"
|
className || ''
|
||||||
src="/images/loading.png"
|
} text-base`}
|
||||||
width={20}
|
>
|
||||||
height={20}
|
<Image
|
||||||
alt="Loading"
|
className="w-5 h-5 mr-4"
|
||||||
/>
|
src="/images/loading.png"
|
||||||
AI is generating|
|
width={20}
|
||||||
</div>
|
height={20}
|
||||||
|
alt="Loading"
|
||||||
|
/>
|
||||||
|
AI is generating|
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default Generating;
|
export default Generating;
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
import { useState } from "react";
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
const Image = ({ className, ...props }) => {
|
interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||||
const [loaded, setLoaded] = useState(false);
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const Image: React.FC<ImageProps> = ({ className, ...props }) => {
|
||||||
<img
|
const [loaded, setLoaded] = useState(false);
|
||||||
className={`inline-block align-top opacity-0 transition-opacity ${
|
|
||||||
loaded && "opacity-100"
|
return (
|
||||||
} ${className}`}
|
<img
|
||||||
onLoad={() => setLoaded(true)}
|
className={`inline-block align-top opacity-0 transition-opacity ${
|
||||||
{...props}
|
loaded && 'opacity-100'
|
||||||
/>
|
} ${className || ''}`}
|
||||||
);
|
onLoad={() => setLoaded(true)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Image;
|
export default Image;
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import Section from "@/components/Section";
|
|
||||||
import Image from "@/components/Image";
|
|
||||||
import Button from "@/components/Button";
|
|
||||||
|
|
||||||
type JoinProps = {};
|
|
||||||
|
|
||||||
const Join = ({}: JoinProps) => (
|
|
||||||
<Section crosses>
|
|
||||||
<div className="container">
|
|
||||||
<div className="relative max-w-[43.125rem] mx-auto py-8 md:py-14 xl:py-0">
|
|
||||||
<div className="relative z-1 text-center">
|
|
||||||
<h1 className="h1 mb-6">
|
|
||||||
Be part of the future of{" "}
|
|
||||||
<span className="inline-block relative">
|
|
||||||
Brainwave
|
|
||||||
<Image
|
|
||||||
className="absolute top-full left-0 w-full"
|
|
||||||
src="/images/curve.png"
|
|
||||||
width={624}
|
|
||||||
height={28}
|
|
||||||
alt="Curve"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<p className="body-1 mb-8 text-n-4">
|
|
||||||
Unleash the power of AI within Brainwave. Upgrade your
|
|
||||||
productivity with Brainwave, the open AI chat app.
|
|
||||||
</p>
|
|
||||||
<Button href="/pricing" white>
|
|
||||||
Get started
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-1/2 left-1/2 w-[46.5rem] h-[46.5rem] border border-n-2/5 rounded-full -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
|
||||||
<div className="absolute top-1/2 left-1/2 w-[39.25rem] h-[39.25rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
|
||||||
<div className="absolute top-1/2 left-1/2 w-[30.625rem] h-[30.625rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
|
||||||
<div className="absolute top-1/2 left-1/2 w-[21.5rem] h-[21.5rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
|
||||||
<div className="absolute top-1/2 left-1/2 w-[13.75rem] h-[13.75rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-1/2 left-1/2 w-[46.5rem] h-[46.5rem] border border-n-2/5 rounded-full -translate-x-1/2 -translate-y-1/2 opacity-60 mix-blend-color-dodge pointer-events-none">
|
|
||||||
<div className="absolute top-1/2 left-1/2 w-[58.85rem] h-[58.85rem] -translate-x-3/4 -translate-y-1/2">
|
|
||||||
<Image
|
|
||||||
className="w-full"
|
|
||||||
src="/images/gradient.png"
|
|
||||||
width={942}
|
|
||||||
height={942}
|
|
||||||
alt="Gradient"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute -top-[5.75rem] left-[18.5rem] -z-1 w-[19.8125rem] pointer-events-none lg:-top-15 lg:left-[5.5rem]">
|
|
||||||
<Image
|
|
||||||
className="w-full"
|
|
||||||
src="/images/join/shapes-1.svg"
|
|
||||||
width={317}
|
|
||||||
height={293}
|
|
||||||
alt="Shapes 1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="absolute right-[15rem] -bottom-[7rem] -z-1 w-[28.1875rem] pointer-events-none lg:right-7 lg:-bottom-[5rem]">
|
|
||||||
<Image
|
|
||||||
className="w-full"
|
|
||||||
src="/images/join/shapes-2.svg"
|
|
||||||
width={451}
|
|
||||||
height={266}
|
|
||||||
alt="Shapes 2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Join;
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import Section from "@/components/Section";
|
|
||||||
import Image from "@/components/Image";
|
|
||||||
import Button from "@/components/Button";
|
|
||||||
|
|
||||||
type JoinProps = {};
|
|
||||||
|
|
||||||
const Join = ({}: JoinProps) => (
|
|
||||||
<Section crosses>
|
|
||||||
<div className="container">
|
|
||||||
<div className="relative max-w-[43.125rem] mx-auto py-8 md:py-14 xl:py-0">
|
|
||||||
<div className="relative z-1 text-center">
|
|
||||||
<h1 className="h1 mb-6">
|
|
||||||
Be part of the future of{" "}
|
|
||||||
<span className="inline-block relative">
|
|
||||||
Brainwave
|
|
||||||
<Image
|
|
||||||
className="absolute top-full left-0 w-full"
|
|
||||||
src="/images/curve.png"
|
|
||||||
width={624}
|
|
||||||
height={28}
|
|
||||||
alt="Curve"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<p className="body-1 mb-8 text-n-4">
|
|
||||||
Unleash the power of AI within Brainwave. Upgrade your
|
|
||||||
productivity with Brainwave, the open AI chat app.
|
|
||||||
</p>
|
|
||||||
<Button href="/pricing" white>
|
|
||||||
Get started
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-1/2 left-1/2 w-[46.5rem] h-[46.5rem] border border-n-2/5 rounded-full -translate-x-1/2 -translate-y-1/2 pointer-events-none">
|
|
||||||
<div className="absolute top-1/2 left-1/2 w-[39.25rem] h-[39.25rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
|
||||||
<div className="absolute top-1/2 left-1/2 w-[30.625rem] h-[30.625rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
|
||||||
<div className="absolute top-1/2 left-1/2 w-[21.5rem] h-[21.5rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
|
||||||
<div className="absolute top-1/2 left-1/2 w-[13.75rem] h-[13.75rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-1/2 left-1/2 w-[46.5rem] h-[46.5rem] border border-n-2/5 rounded-full -translate-x-1/2 -translate-y-1/2 opacity-60 mix-blend-color-dodge pointer-events-none">
|
|
||||||
<div className="absolute top-1/2 left-1/2 w-[58.85rem] h-[58.85rem] -translate-x-3/4 -translate-y-1/2">
|
|
||||||
<Image
|
|
||||||
className="w-full"
|
|
||||||
src="/images/gradient.png"
|
|
||||||
width={942}
|
|
||||||
height={942}
|
|
||||||
alt="Gradient"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute -top-[5.75rem] left-[18.5rem] -z-1 w-[19.8125rem] pointer-events-none lg:-top-15 lg:left-[5.5rem]">
|
|
||||||
<Image
|
|
||||||
className="w-full"
|
|
||||||
src="/images/join/shapes-1.svg"
|
|
||||||
width={317}
|
|
||||||
height={293}
|
|
||||||
alt="Shapes 1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="absolute right-[15rem] -bottom-[7rem] -z-1 w-[28.1875rem] pointer-events-none lg:right-7 lg:-bottom-[5rem]">
|
|
||||||
<Image
|
|
||||||
className="w-full"
|
|
||||||
src="/images/join/shapes-2.svg"
|
|
||||||
width={451}
|
|
||||||
height={266}
|
|
||||||
alt="Shapes 2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Join;
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import Image from "../Image";
|
|
||||||
|
|
||||||
const Logos = ({ className }) => (
|
|
||||||
<div className={className}>
|
|
||||||
<h5 className="tagline mb-6 text-center text-n-1/50">
|
|
||||||
Helping people create beautiful content at
|
|
||||||
</h5>
|
|
||||||
<ul className="flex">
|
|
||||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
|
||||||
<Image
|
|
||||||
src="/images/yourlogo.svg"
|
|
||||||
width={134}
|
|
||||||
height={28}
|
|
||||||
alt="Logo 3"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
|
||||||
<Image
|
|
||||||
src="/images/yourlogo.svg"
|
|
||||||
width={134}
|
|
||||||
height={28}
|
|
||||||
alt="Logo 3"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
|
||||||
<Image
|
|
||||||
src="/images/yourlogo.svg"
|
|
||||||
width={134}
|
|
||||||
height={28}
|
|
||||||
alt="Logo 3"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
|
||||||
<Image
|
|
||||||
src="/images/yourlogo.svg"
|
|
||||||
width={134}
|
|
||||||
height={28}
|
|
||||||
alt="Logo 3"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
|
||||||
<Image
|
|
||||||
src="/images/yourlogo.svg"
|
|
||||||
width={134}
|
|
||||||
height={28}
|
|
||||||
alt="Logo 3"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Logos;
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import Image from "../Image";
|
|
||||||
|
|
||||||
const Logos = ({ className }) => (
|
|
||||||
<div className={className}>
|
|
||||||
<h5 className="tagline mb-6 text-center text-n-1/50">
|
|
||||||
Helping people create beautiful content at
|
|
||||||
</h5>
|
|
||||||
<ul className="flex">
|
|
||||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
|
||||||
<Image
|
|
||||||
src="/images/yourlogo.svg"
|
|
||||||
width={134}
|
|
||||||
height={28}
|
|
||||||
alt="Logo 3"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
|
||||||
<Image
|
|
||||||
src="/images/yourlogo.svg"
|
|
||||||
width={134}
|
|
||||||
height={28}
|
|
||||||
alt="Logo 3"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
|
||||||
<Image
|
|
||||||
src="/images/yourlogo.svg"
|
|
||||||
width={134}
|
|
||||||
height={28}
|
|
||||||
alt="Logo 3"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
|
||||||
<Image
|
|
||||||
src="/images/yourlogo.svg"
|
|
||||||
width={134}
|
|
||||||
height={28}
|
|
||||||
alt="Logo 3"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
|
|
||||||
<Image
|
|
||||||
src="/images/yourlogo.svg"
|
|
||||||
width={134}
|
|
||||||
height={28}
|
|
||||||
alt="Logo 3"
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Logos;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import Image from "../Image";
|
|
||||||
|
|
||||||
const Notification = ({ className, title }) => (
|
|
||||||
<div
|
|
||||||
className={`flex items-center p-4 pr-6 bg-[#474060]/40 backdrop-blur border border-n-1/10 rounded-2xl ${
|
|
||||||
className || ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mr-5">
|
|
||||||
<Image
|
|
||||||
className="w-full rounded-xl"
|
|
||||||
src="/images/notification/image-1.png"
|
|
||||||
width={52}
|
|
||||||
height={52}
|
|
||||||
alt="Image"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h6 className="mb-1 font-semibold text-base">{title}</h6>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<ul className="flex -m-0.5">
|
|
||||||
{[
|
|
||||||
"/images/notification/image-4.png",
|
|
||||||
"/images/notification/image-3.png",
|
|
||||||
"/images/notification/image-2.png",
|
|
||||||
].map((item, index) => (
|
|
||||||
<li
|
|
||||||
className={`flex w-6 h-6 border-2 border-[#2E2A41] rounded-full overflow-hidden ${
|
|
||||||
index !== 0 ? "-ml-2" : ""
|
|
||||||
}`}
|
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="w-full"
|
|
||||||
src={item}
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
alt={item}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div className="body-2 text-[#6C7275]">1m ago</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Notification;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import Image from "../Image";
|
|
||||||
|
|
||||||
const Notification = ({ className, title }) => (
|
|
||||||
<div
|
|
||||||
className={`flex items-center p-4 pr-6 bg-[#474060]/40 backdrop-blur border border-n-1/10 rounded-2xl ${
|
|
||||||
className || ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="mr-5">
|
|
||||||
<Image
|
|
||||||
className="w-full rounded-xl"
|
|
||||||
src="/images/notification/image-1.png"
|
|
||||||
width={52}
|
|
||||||
height={52}
|
|
||||||
alt="Image"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<h6 className="mb-1 font-semibold text-base">{title}</h6>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<ul className="flex -m-0.5">
|
|
||||||
{[
|
|
||||||
"/images/notification/image-4.png",
|
|
||||||
"/images/notification/image-3.png",
|
|
||||||
"/images/notification/image-2.png",
|
|
||||||
].map((item, index) => (
|
|
||||||
<li
|
|
||||||
className={`flex w-6 h-6 border-2 border-[#2E2A41] rounded-full overflow-hidden ${
|
|
||||||
index !== 0 ? "-ml-2" : ""
|
|
||||||
}`}
|
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="w-full"
|
|
||||||
src={item}
|
|
||||||
width={20}
|
|
||||||
height={20}
|
|
||||||
alt={item}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div className="body-2 text-[#6C7275]">1m ago</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Notification;
|
|
||||||
@@ -1,57 +1,67 @@
|
|||||||
const Section = ({
|
import React from 'react';
|
||||||
className,
|
|
||||||
crosses,
|
interface SectionProps {
|
||||||
crossesOffset,
|
className?: string;
|
||||||
customPaddings,
|
crosses?: boolean;
|
||||||
children,
|
crossesOffset?: string;
|
||||||
|
customPaddings?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Section: React.FC<SectionProps> = ({
|
||||||
|
className,
|
||||||
|
crosses,
|
||||||
|
crossesOffset,
|
||||||
|
customPaddings,
|
||||||
|
children,
|
||||||
}) => (
|
}) => (
|
||||||
<div
|
<div
|
||||||
className={`relative ${
|
className={`relative ${
|
||||||
customPaddings ||
|
customPaddings ||
|
||||||
`py-10 lg:py-16 xl:py-20 ${crosses ? "lg:py-32 xl:py-40" : ""}`
|
`py-10 lg:py-16 xl:py-20 ${crosses ? 'lg:py-32 xl:py-40' : ''}`
|
||||||
} ${className || ""}`}
|
} ${className || ''}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<div className="hidden absolute top-0 left-5 w-0.25 h-full bg-stroke-1 pointer-events-none md:block lg:left-7.5 xl:left-10"></div>
|
<div className="hidden absolute top-0 left-5 w-0.25 h-full bg-stroke-1 pointer-events-none md:block lg:left-7.5 xl:left-10"></div>
|
||||||
<div className="hidden absolute top-0 right-5 w-0.25 h-full bg-stroke-1 pointer-events-none md:block lg:right-7.5 xl:right-10"></div>
|
<div className="hidden absolute top-0 right-5 w-0.25 h-full bg-stroke-1 pointer-events-none md:block lg:right-7.5 xl:right-10"></div>
|
||||||
{crosses && (
|
{crosses && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`hidden absolute top-0 left-7.5 right-7.5 h-0.25 bg-stroke-1 ${
|
className={`hidden absolute top-0 left-7.5 right-7.5 h-0.25 bg-stroke-1 ${
|
||||||
crossesOffset && crossesOffset
|
crossesOffset && crossesOffset
|
||||||
} pointer-events-none lg:block xl:left-10 right-10`}
|
} pointer-events-none lg:block xl:left-10 right-10`}
|
||||||
></div>
|
></div>
|
||||||
<svg
|
<svg
|
||||||
className={`hidden absolute -top-[0.3125rem] left-[1.5625rem] ${
|
className={`hidden absolute -top-[0.3125rem] left-[1.5625rem] ${
|
||||||
crossesOffset && crossesOffset
|
crossesOffset && crossesOffset
|
||||||
} pointer-events-none lg:block xl:left-[2.1875rem]`}
|
} pointer-events-none lg:block xl:left-[2.1875rem]`}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="11"
|
width="11"
|
||||||
height="11"
|
height="11"
|
||||||
fill="none"
|
fill="none"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
|
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
|
||||||
fill="#ada8c4"
|
fill="#ada8c4"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<svg
|
<svg
|
||||||
className={`hidden absolute -top-[0.3125rem] right-[1.5625rem] ${
|
className={`hidden absolute -top-[0.3125rem] right-[1.5625rem] ${
|
||||||
crossesOffset && crossesOffset
|
crossesOffset && crossesOffset
|
||||||
} pointer-events-none lg:block xl:right-[2.1875rem]`}
|
} pointer-events-none lg:block xl:right-[2.1875rem]`}
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="11"
|
width="11"
|
||||||
height="11"
|
height="11"
|
||||||
fill="none"
|
fill="none"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
|
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
|
||||||
fill="#ada8c4"
|
fill="#ada8c4"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default Section;
|
export default Section;
|
||||||
|
|||||||
@@ -1,195 +0,0 @@
|
|||||||
import Section from "@/components/Section";
|
|
||||||
import Generating from "@/components/Generating";
|
|
||||||
import Image from "@/components/Image";
|
|
||||||
import Heading from "@/components/Heading";
|
|
||||||
|
|
||||||
type ServicesProps = {
|
|
||||||
containerClassName?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Services = ({ containerClassName }: ServicesProps) => (
|
|
||||||
<Section>
|
|
||||||
<div className={`container ${containerClassName || ""}`}>
|
|
||||||
<Heading
|
|
||||||
title="Generative AI made for creators."
|
|
||||||
text="Brainwave unlocks the potential of AI-powered applications"
|
|
||||||
/>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="relative z-1 flex items-center h-[38.75rem] mb-5 p-8 border border-n-1/10 rounded-3xl overflow-hidden lg:h-[38.75rem] lg:p-20 xl:h-[45.75rem]">
|
|
||||||
<div className="absolute top-0 left-0 w-full h-full pointer-events-none md:w-3/5 xl:w-auto">
|
|
||||||
<Image
|
|
||||||
className="w-full h-full object-cover md:object-right"
|
|
||||||
src="/images/services/service-1.png"
|
|
||||||
width={797}
|
|
||||||
height={733}
|
|
||||||
alt="Smartest AI"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="relative z-1 max-w-[17rem] ml-auto">
|
|
||||||
<h4 className="h4 mb-4">Smartest AI</h4>
|
|
||||||
<p className="bpdy-2 mb-[3.125rem] text-n-3">
|
|
||||||
Brainwave unlocks the potential of AI-powered
|
|
||||||
applications
|
|
||||||
</p>
|
|
||||||
<ul className="body-2">
|
|
||||||
{[
|
|
||||||
"Photo generating",
|
|
||||||
"Photo enhance",
|
|
||||||
"Seamless Integration",
|
|
||||||
].map((item, index) => (
|
|
||||||
<li
|
|
||||||
className="flex items-start py-4 border-t border-n-6"
|
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/images/check.svg"
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
alt="Check"
|
|
||||||
/>
|
|
||||||
<p className="ml-4">{item}</p>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<Generating className="absolute left-4 right-4 bottom-4 border border-n-1/10 lg:left-1/2 lg-right-auto lg:bottom-8 lg:-translate-x-1/2" />
|
|
||||||
</div>
|
|
||||||
<div className="relative z-1 grid gap-5 lg:grid-cols-2">
|
|
||||||
<div className="relative min-h-[38.75rem] border border-n-1/10 rounded-3xl overflow-hidden">
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<Image
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
src="/images/services/service-2.png"
|
|
||||||
width={630}
|
|
||||||
height={748}
|
|
||||||
alt="Smartest AI"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-0 flex flex-col justify-end p-8 bg-gradient-to-b from-n-8/0 to-n-8/90 lg:p-15">
|
|
||||||
<h4 className="h4 mb-4">Photo editing</h4>
|
|
||||||
<p className="body-2 text-n-3">
|
|
||||||
{`Automatically enhance your photos using our AI app's
|
|
||||||
photo editing feature. Try it now!`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-8 right-8 max-w-[17.5rem] py-6 px-8 bg-black rounded-t-xl rounded-bl-xl font-code text-base lg:top-16 lg:right-[8.75rem] lg:max-w-[17.5rem]">
|
|
||||||
Hey Brainwave, enhance this photo
|
|
||||||
<svg
|
|
||||||
className="absolute left-full bottom-0"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="26"
|
|
||||||
height="37"
|
|
||||||
>
|
|
||||||
<path d="M21.843 37.001c3.564 0 5.348-4.309 2.829-6.828L3.515 9.015A12 12 0 0 1 0 .53v36.471h21.843z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-n-7 rounded-3xl overflow-hidden lg:min-h-[45.75rem]">
|
|
||||||
<div className="py-12 px-4 xl:px-8">
|
|
||||||
<h4 className="h4 mb-4">Video generation</h4>
|
|
||||||
<p className="body-2 mb-[2.25rem] text-n-3">
|
|
||||||
The world’s most powerful AI photo and video art
|
|
||||||
generation engine.What will you create?
|
|
||||||
</p>
|
|
||||||
<ul className="flex items-center justify-between">
|
|
||||||
{[
|
|
||||||
"/images/icons/recording-03.svg",
|
|
||||||
"/images/icons/recording-01.svg",
|
|
||||||
"/images/icons/disc-02.svg",
|
|
||||||
"/images/icons/chrome-cast.svg",
|
|
||||||
"/images/icons/sliders-04.svg",
|
|
||||||
].map((item, index) => (
|
|
||||||
<li
|
|
||||||
className={`flex items-center justify-center ${
|
|
||||||
index === 2
|
|
||||||
? "w-[3rem] h-[3rem] p-0.25 bg-conic-gradient rounded-2xl md:w-[4.5rem] md:h-[4.5rem]"
|
|
||||||
: "flex w-10 h-10 bg-n-6 rounded-2xl md:w-15 md:h-15"
|
|
||||||
}`}
|
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
index === 2
|
|
||||||
? "flex items-center justify-center w-full h-full bg-n-7 rounded-[0.9375rem]"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={item}
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
alt={item}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="relative h-[20.5rem] bg-n-8 rounded-xl overflow-hidden md:h-[25rem]">
|
|
||||||
<Image
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
src="/images/services/service-3.png"
|
|
||||||
width={517}
|
|
||||||
height={400}
|
|
||||||
alt="Smartest AI"
|
|
||||||
/>
|
|
||||||
<div className="absolute top-8 left-[3.125rem] w-full max-w-[14rem] pt-2.5 pr-2.5 pb-7 pl-5 bg-n-6 rounded-t-xl rounded-br-xl font-code text-base md:max-w-[17.5rem]">
|
|
||||||
Video generated!
|
|
||||||
<div className="absolute left-5 -bottom-[1.125rem] flex items-center justify-center w-[2.25rem] h-[2.25rem] bg-color-1 rounded-[0.75rem]">
|
|
||||||
<Image
|
|
||||||
src="/images/brainwave-symbol-white.svg"
|
|
||||||
width={26}
|
|
||||||
height={26}
|
|
||||||
alt="Brainwave"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="tagline absolute right-2.5 bottom-1 text-[0.625rem] text-n-3 uppercase">
|
|
||||||
just now
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
className="absolute right-full bottom-0 -scale-x-100"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="26"
|
|
||||||
height="37"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
className="fill-n-6"
|
|
||||||
d="M21.843 37.001c3.564 0 5.348-4.309 2.829-6.828L3.515 9.015A12 12 0 0 1 0 .53v36.471h21.843z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="absolute left-0 bottom-0 w-full flex items-center p-6">
|
|
||||||
<svg
|
|
||||||
className="mr-3"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M8.006 2.802l.036.024 10.549 7.032.805.567c.227.183.494.437.648.808a2 2 0 0 1 0 1.532c-.154.371-.421.625-.648.808-.217.175-.5.364-.805.567L8.006 21.198l-.993.627c-.285.154-.676.331-1.132.303a2 2 0 0 1-1.476-.79c-.276-.365-.346-.788-.375-1.111S4 19.502 4 19.054V4.99v-.043l.029-1.174c.03-.323.1-.746.375-1.11a2 2 0 0 1 1.476-.79c.456-.027.847.149 1.132.304s.62.378.993.627z"
|
|
||||||
fill="#fff"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div className="flex-1 bg-[#D9D9D9]">
|
|
||||||
<div className="w-1/2 h-0.5 bg-color-1"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-0 -left-[10rem] w-[56.625rem] h-[56.625rem] opacity-50 mix-blend-color-dodge pointer-events-none">
|
|
||||||
<Image
|
|
||||||
className="absolute top-1/2 left-1/2 w-[79.5625rem] max-w-[79.5625rem] h-[88.5625rem] -translate-x-1/2 -translate-y-1/2"
|
|
||||||
src="/images/gradient.png"
|
|
||||||
width={1417}
|
|
||||||
height={1417}
|
|
||||||
alt="Gradient"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Services;
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import Section from "@/components/Section";
|
|
||||||
import Generating from "@/components/Generating";
|
|
||||||
import Image from "@/components/Image";
|
|
||||||
import Heading from "@/components/Heading";
|
|
||||||
|
|
||||||
type ServicesProps = {
|
|
||||||
containerClassName?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Services = ({ containerClassName }: ServicesProps) => (
|
|
||||||
<Section>
|
|
||||||
<div className={`container ${containerClassName || ""}`}>
|
|
||||||
<Heading
|
|
||||||
title="Generative AI made for creators."
|
|
||||||
text="Brainwave unlocks the potential of AI-powered applications"
|
|
||||||
/>
|
|
||||||
<div className="relative">
|
|
||||||
<div className="relative z-1 flex items-center h-[38.75rem] mb-5 p-8 border border-n-1/10 rounded-3xl overflow-hidden lg:h-[38.75rem] lg:p-20 xl:h-[45.75rem]">
|
|
||||||
<div className="absolute top-0 left-0 w-full h-full pointer-events-none md:w-3/5 xl:w-auto">
|
|
||||||
<Image
|
|
||||||
className="w-full h-full object-cover md:object-right"
|
|
||||||
src="/images/services/service-1.png"
|
|
||||||
width={797}
|
|
||||||
height={733}
|
|
||||||
alt="Smartest AI"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="relative z-1 max-w-[17rem] ml-auto">
|
|
||||||
<h4 className="h4 mb-4">Smartest AI</h4>
|
|
||||||
<p className="bpdy-2 mb-[3.125rem] text-n-3">
|
|
||||||
Brainwave unlocks the potential of AI-powered
|
|
||||||
applications
|
|
||||||
</p>
|
|
||||||
<ul className="body-2">
|
|
||||||
{[
|
|
||||||
"Photo generating",
|
|
||||||
"Photo enhance",
|
|
||||||
"Seamless Integration",
|
|
||||||
].map((item, index) => (
|
|
||||||
<li
|
|
||||||
className="flex items-start py-4 border-t border-n-6"
|
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src="/images/check.svg"
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
alt="Check"
|
|
||||||
/>
|
|
||||||
<p className="ml-4">{item}</p>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<Generating className="absolute left-4 right-4 bottom-4 border border-n-1/10 lg:left-1/2 lg-right-auto lg:bottom-8 lg:-translate-x-1/2" />
|
|
||||||
</div>
|
|
||||||
<div className="relative z-1 grid gap-5 lg:grid-cols-2">
|
|
||||||
<div className="relative min-h-[38.75rem] border border-n-1/10 rounded-3xl overflow-hidden">
|
|
||||||
<div className="absolute inset-0">
|
|
||||||
<Image
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
src="/images/services/service-2.png"
|
|
||||||
width={630}
|
|
||||||
height={748}
|
|
||||||
alt="Smartest AI"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-0 flex flex-col justify-end p-8 bg-gradient-to-b from-n-8/0 to-n-8/90 lg:p-15">
|
|
||||||
<h4 className="h4 mb-4">Photo editing</h4>
|
|
||||||
<p className="body-2 text-n-3">
|
|
||||||
{`Automatically enhance your photos using our AI app's
|
|
||||||
photo editing feature. Try it now!`}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-8 right-8 max-w-[17.5rem] py-6 px-8 bg-black rounded-t-xl rounded-bl-xl font-code text-base lg:top-16 lg:right-[8.75rem] lg:max-w-[17.5rem]">
|
|
||||||
Hey Brainwave, enhance this photo
|
|
||||||
<svg
|
|
||||||
className="absolute left-full bottom-0"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="26"
|
|
||||||
height="37"
|
|
||||||
>
|
|
||||||
<path d="M21.843 37.001c3.564 0 5.348-4.309 2.829-6.828L3.515 9.015A12 12 0 0 1 0 .53v36.471h21.843z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-n-7 rounded-3xl overflow-hidden lg:min-h-[45.75rem]">
|
|
||||||
<div className="py-12 px-4 xl:px-8">
|
|
||||||
<h4 className="h4 mb-4">Video generation</h4>
|
|
||||||
<p className="body-2 mb-[2.25rem] text-n-3">
|
|
||||||
The world’s most powerful AI photo and video art
|
|
||||||
generation engine.What will you create?
|
|
||||||
</p>
|
|
||||||
<ul className="flex items-center justify-between">
|
|
||||||
{[
|
|
||||||
"/images/icons/recording-03.svg",
|
|
||||||
"/images/icons/recording-01.svg",
|
|
||||||
"/images/icons/disc-02.svg",
|
|
||||||
"/images/icons/chrome-cast.svg",
|
|
||||||
"/images/icons/sliders-04.svg",
|
|
||||||
].map((item, index) => (
|
|
||||||
<li
|
|
||||||
className={`flex items-center justify-center ${
|
|
||||||
index === 2
|
|
||||||
? "w-[3rem] h-[3rem] p-0.25 bg-conic-gradient rounded-2xl md:w-[4.5rem] md:h-[4.5rem]"
|
|
||||||
: "flex w-10 h-10 bg-n-6 rounded-2xl md:w-15 md:h-15"
|
|
||||||
}`}
|
|
||||||
key={index}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={
|
|
||||||
index === 2
|
|
||||||
? "flex items-center justify-center w-full h-full bg-n-7 rounded-[0.9375rem]"
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
src={item}
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
alt={item}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div className="relative h-[20.5rem] bg-n-8 rounded-xl overflow-hidden md:h-[25rem]">
|
|
||||||
<Image
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
src="/images/services/service-3.png"
|
|
||||||
width={517}
|
|
||||||
height={400}
|
|
||||||
alt="Smartest AI"
|
|
||||||
/>
|
|
||||||
<div className="absolute top-8 left-[3.125rem] w-full max-w-[14rem] pt-2.5 pr-2.5 pb-7 pl-5 bg-n-6 rounded-t-xl rounded-br-xl font-code text-base md:max-w-[17.5rem]">
|
|
||||||
Video generated!
|
|
||||||
<div className="absolute left-5 -bottom-[1.125rem] flex items-center justify-center w-[2.25rem] h-[2.25rem] bg-color-1 rounded-[0.75rem]">
|
|
||||||
<Image
|
|
||||||
src="/images/brainwave-symbol-white.svg"
|
|
||||||
width={26}
|
|
||||||
height={26}
|
|
||||||
alt="Brainwave"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="tagline absolute right-2.5 bottom-1 text-[0.625rem] text-n-3 uppercase">
|
|
||||||
just now
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
className="absolute right-full bottom-0 -scale-x-100"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="26"
|
|
||||||
height="37"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
className="fill-n-6"
|
|
||||||
d="M21.843 37.001c3.564 0 5.348-4.309 2.829-6.828L3.515 9.015A12 12 0 0 1 0 .53v36.471h21.843z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="absolute left-0 bottom-0 w-full flex items-center p-6">
|
|
||||||
<svg
|
|
||||||
className="mr-3"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
fill="none"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M8.006 2.802l.036.024 10.549 7.032.805.567c.227.183.494.437.648.808a2 2 0 0 1 0 1.532c-.154.371-.421.625-.648.808-.217.175-.5.364-.805.567L8.006 21.198l-.993.627c-.285.154-.676.331-1.132.303a2 2 0 0 1-1.476-.79c-.276-.365-.346-.788-.375-1.111S4 19.502 4 19.054V4.99v-.043l.029-1.174c.03-.323.1-.746.375-1.11a2 2 0 0 1 1.476-.79c.456-.027.847.149 1.132.304s.62.378.993.627z"
|
|
||||||
fill="#fff"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div className="flex-1 bg-[#D9D9D9]">
|
|
||||||
<div className="w-1/2 h-0.5 bg-color-1"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute top-0 -left-[10rem] w-[56.625rem] h-[56.625rem] opacity-50 mix-blend-color-dodge pointer-events-none">
|
|
||||||
<Image
|
|
||||||
className="absolute top-1/2 left-1/2 w-[79.5625rem] max-w-[79.5625rem] h-[88.5625rem] -translate-x-1/2 -translate-y-1/2"
|
|
||||||
src="/images/gradient.png"
|
|
||||||
width={1417}
|
|
||||||
height={1417}
|
|
||||||
alt="Gradient"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Services;
|
|
||||||
@@ -1,394 +0,0 @@
|
|||||||
// src/hooks/useSubscriptionEvents.js
|
|
||||||
// 订阅和支付事件追踪 Hook
|
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { usePostHogTrack } from './usePostHogRedux';
|
|
||||||
import { RETENTION_EVENTS, REVENUE_EVENTS } from '../lib/constants';
|
|
||||||
import { logger } from '../utils/logger';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 订阅和支付事件追踪 Hook
|
|
||||||
* @param {Object} options - 配置选项
|
|
||||||
* @param {Object} options.currentSubscription - 当前订阅信息
|
|
||||||
* @returns {Object} 事件追踪处理函数集合
|
|
||||||
*/
|
|
||||||
export const useSubscriptionEvents = ({ currentSubscription = null } = {}) => {
|
|
||||||
const { track } = usePostHogTrack();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪付费墙展示
|
|
||||||
* @param {string} feature - 被限制的功能名称
|
|
||||||
* @param {string} requiredPlan - 需要的订阅计划
|
|
||||||
* @param {string} triggerLocation - 触发位置
|
|
||||||
*/
|
|
||||||
const trackPaywallShown = useCallback((feature, requiredPlan = 'pro', triggerLocation = '') => {
|
|
||||||
if (!feature) {
|
|
||||||
logger.warn('useSubscriptionEvents', 'trackPaywallShown: feature is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
track(REVENUE_EVENTS.PAYWALL_SHOWN, {
|
|
||||||
feature,
|
|
||||||
required_plan: requiredPlan,
|
|
||||||
current_plan: currentSubscription?.plan || 'free',
|
|
||||||
trigger_location: triggerLocation,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSubscriptionEvents', '🚧 Paywall Shown', {
|
|
||||||
feature,
|
|
||||||
requiredPlan,
|
|
||||||
triggerLocation,
|
|
||||||
});
|
|
||||||
}, [track, currentSubscription]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪付费墙关闭
|
|
||||||
* @param {string} feature - 功能名称
|
|
||||||
* @param {string} closeMethod - 关闭方式 ('dismiss' | 'upgrade_clicked' | 'back_button')
|
|
||||||
*/
|
|
||||||
const trackPaywallDismissed = useCallback((feature, closeMethod = 'dismiss') => {
|
|
||||||
if (!feature) {
|
|
||||||
logger.warn('useSubscriptionEvents', 'trackPaywallDismissed: feature is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
track(REVENUE_EVENTS.PAYWALL_DISMISSED, {
|
|
||||||
feature,
|
|
||||||
close_method: closeMethod,
|
|
||||||
current_plan: currentSubscription?.plan || 'free',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSubscriptionEvents', '❌ Paywall Dismissed', {
|
|
||||||
feature,
|
|
||||||
closeMethod,
|
|
||||||
});
|
|
||||||
}, [track, currentSubscription]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪升级按钮点击
|
|
||||||
* @param {string} targetPlan - 目标订阅计划
|
|
||||||
* @param {string} source - 来源位置
|
|
||||||
* @param {string} feature - 关联的功能(如果从付费墙点击)
|
|
||||||
*/
|
|
||||||
const trackUpgradePlanClicked = useCallback((targetPlan = 'pro', source = '', feature = '') => {
|
|
||||||
track(REVENUE_EVENTS.PAYWALL_UPGRADE_CLICKED, {
|
|
||||||
current_plan: currentSubscription?.plan || 'free',
|
|
||||||
target_plan: targetPlan,
|
|
||||||
source,
|
|
||||||
feature: feature || null,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSubscriptionEvents', '⬆️ Upgrade Plan Clicked', {
|
|
||||||
currentPlan: currentSubscription?.plan,
|
|
||||||
targetPlan,
|
|
||||||
source,
|
|
||||||
feature,
|
|
||||||
});
|
|
||||||
}, [track, currentSubscription]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪订阅页面查看
|
|
||||||
* @param {string} source - 来源
|
|
||||||
*/
|
|
||||||
const trackSubscriptionPageViewed = useCallback((source = '') => {
|
|
||||||
track(RETENTION_EVENTS.SUBSCRIPTION_PAGE_VIEWED, {
|
|
||||||
current_plan: currentSubscription?.plan || 'free',
|
|
||||||
subscription_status: currentSubscription?.status || 'unknown',
|
|
||||||
is_paid_user: currentSubscription?.plan && currentSubscription.plan !== 'free',
|
|
||||||
source,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSubscriptionEvents', '💳 Subscription Page Viewed', {
|
|
||||||
currentPlan: currentSubscription?.plan,
|
|
||||||
source,
|
|
||||||
});
|
|
||||||
}, [track, currentSubscription]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪定价计划查看
|
|
||||||
* @param {string} planName - 计划名称 ('free' | 'pro' | 'enterprise')
|
|
||||||
* @param {number} price - 价格
|
|
||||||
*/
|
|
||||||
const trackPricingPlanViewed = useCallback((planName, price = 0) => {
|
|
||||||
if (!planName) {
|
|
||||||
logger.warn('useSubscriptionEvents', 'trackPricingPlanViewed: planName is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
track('Pricing Plan Viewed', {
|
|
||||||
plan_name: planName,
|
|
||||||
price,
|
|
||||||
current_plan: currentSubscription?.plan || 'free',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSubscriptionEvents', '👀 Pricing Plan Viewed', {
|
|
||||||
planName,
|
|
||||||
price,
|
|
||||||
});
|
|
||||||
}, [track, currentSubscription]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪定价计划选择
|
|
||||||
* @param {string} planName - 选择的计划名称
|
|
||||||
* @param {string} billingCycle - 计费周期 ('monthly' | 'yearly')
|
|
||||||
* @param {number} price - 价格
|
|
||||||
*/
|
|
||||||
const trackPricingPlanSelected = useCallback((planName, billingCycle = 'monthly', price = 0) => {
|
|
||||||
if (!planName) {
|
|
||||||
logger.warn('useSubscriptionEvents', 'trackPricingPlanSelected: planName is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
track('Pricing Plan Selected', {
|
|
||||||
plan_name: planName,
|
|
||||||
billing_cycle: billingCycle,
|
|
||||||
price,
|
|
||||||
current_plan: currentSubscription?.plan || 'free',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSubscriptionEvents', '✅ Pricing Plan Selected', {
|
|
||||||
planName,
|
|
||||||
billingCycle,
|
|
||||||
price,
|
|
||||||
});
|
|
||||||
}, [track, currentSubscription]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪支付页面查看
|
|
||||||
* @param {string} planName - 购买的计划
|
|
||||||
* @param {number} amount - 支付金额
|
|
||||||
*/
|
|
||||||
const trackPaymentPageViewed = useCallback((planName, amount = 0) => {
|
|
||||||
track(REVENUE_EVENTS.PAYMENT_PAGE_VIEWED, {
|
|
||||||
plan_name: planName,
|
|
||||||
amount,
|
|
||||||
current_plan: currentSubscription?.plan || 'free',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSubscriptionEvents', '💰 Payment Page Viewed', {
|
|
||||||
planName,
|
|
||||||
amount,
|
|
||||||
});
|
|
||||||
}, [track, currentSubscription]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪支付方式选择
|
|
||||||
* @param {string} paymentMethod - 支付方式 ('wechat_pay' | 'alipay' | 'credit_card')
|
|
||||||
* @param {number} amount - 支付金额
|
|
||||||
*/
|
|
||||||
const trackPaymentMethodSelected = useCallback((paymentMethod, amount = 0) => {
|
|
||||||
if (!paymentMethod) {
|
|
||||||
logger.warn('useSubscriptionEvents', 'trackPaymentMethodSelected: paymentMethod is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
track(REVENUE_EVENTS.PAYMENT_METHOD_SELECTED, {
|
|
||||||
payment_method: paymentMethod,
|
|
||||||
amount,
|
|
||||||
current_plan: currentSubscription?.plan || 'free',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSubscriptionEvents', '💳 Payment Method Selected', {
|
|
||||||
paymentMethod,
|
|
||||||
amount,
|
|
||||||
});
|
|
||||||
}, [track, currentSubscription]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪支付发起
|
|
||||||
* @param {Object} paymentInfo - 支付信息
|
|
||||||
* @param {string} paymentInfo.planName - 计划名称
|
|
||||||
* @param {string} paymentInfo.paymentMethod - 支付方式
|
|
||||||
* @param {number} paymentInfo.amount - 金额
|
|
||||||
* @param {string} paymentInfo.billingCycle - 计费周期
|
|
||||||
* @param {string} paymentInfo.orderId - 订单ID
|
|
||||||
*/
|
|
||||||
const trackPaymentInitiated = useCallback((paymentInfo = {}) => {
|
|
||||||
track(REVENUE_EVENTS.PAYMENT_INITIATED, {
|
|
||||||
plan_name: paymentInfo.planName,
|
|
||||||
payment_method: paymentInfo.paymentMethod,
|
|
||||||
amount: paymentInfo.amount,
|
|
||||||
billing_cycle: paymentInfo.billingCycle,
|
|
||||||
order_id: paymentInfo.orderId,
|
|
||||||
current_plan: currentSubscription?.plan || 'free',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSubscriptionEvents', '🚀 Payment Initiated', {
|
|
||||||
planName: paymentInfo.planName,
|
|
||||||
amount: paymentInfo.amount,
|
|
||||||
paymentMethod: paymentInfo.paymentMethod,
|
|
||||||
});
|
|
||||||
}, [track, currentSubscription]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪支付成功
|
|
||||||
* @param {Object} paymentInfo - 支付信息
|
|
||||||
*/
|
|
||||||
const trackPaymentSuccessful = useCallback((paymentInfo = {}) => {
|
|
||||||
track(REVENUE_EVENTS.PAYMENT_SUCCESSFUL, {
|
|
||||||
plan_name: paymentInfo.planName,
|
|
||||||
payment_method: paymentInfo.paymentMethod,
|
|
||||||
amount: paymentInfo.amount,
|
|
||||||
billing_cycle: paymentInfo.billingCycle,
|
|
||||||
order_id: paymentInfo.orderId,
|
|
||||||
transaction_id: paymentInfo.transactionId,
|
|
||||||
previous_plan: currentSubscription?.plan || 'free',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSubscriptionEvents', '✅ Payment Successful', {
|
|
||||||
planName: paymentInfo.planName,
|
|
||||||
amount: paymentInfo.amount,
|
|
||||||
orderId: paymentInfo.orderId,
|
|
||||||
});
|
|
||||||
}, [track, currentSubscription]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪支付失败
|
|
||||||
* @param {Object} paymentInfo - 支付信息
|
|
||||||
* @param {string} errorReason - 失败原因
|
|
||||||
*/
|
|
||||||
const trackPaymentFailed = useCallback((paymentInfo = {}, errorReason = '') => {
|
|
||||||
track(REVENUE_EVENTS.PAYMENT_FAILED, {
|
|
||||||
plan_name: paymentInfo.planName,
|
|
||||||
payment_method: paymentInfo.paymentMethod,
|
|
||||||
amount: paymentInfo.amount,
|
|
||||||
error_reason: errorReason,
|
|
||||||
order_id: paymentInfo.orderId,
|
|
||||||
current_plan: currentSubscription?.plan || 'free',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSubscriptionEvents', '❌ Payment Failed', {
|
|
||||||
planName: paymentInfo.planName,
|
|
||||||
errorReason,
|
|
||||||
orderId: paymentInfo.orderId,
|
|
||||||
});
|
|
||||||
}, [track, currentSubscription]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪订阅创建成功
|
|
||||||
* @param {Object} subscription - 订阅信息
|
|
||||||
*/
|
|
||||||
const trackSubscriptionCreated = useCallback((subscription = {}) => {
|
|
||||||
track(REVENUE_EVENTS.SUBSCRIPTION_CREATED, {
|
|
||||||
plan_name: subscription.plan,
|
|
||||||
billing_cycle: subscription.billingCycle,
|
|
||||||
amount: subscription.amount,
|
|
||||||
start_date: subscription.startDate,
|
|
||||||
end_date: subscription.endDate,
|
|
||||||
previous_plan: currentSubscription?.plan || 'free',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSubscriptionEvents', '🎉 Subscription Created', {
|
|
||||||
plan: subscription.plan,
|
|
||||||
billingCycle: subscription.billingCycle,
|
|
||||||
});
|
|
||||||
}, [track, currentSubscription]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪订阅续费
|
|
||||||
* @param {Object} subscription - 订阅信息
|
|
||||||
*/
|
|
||||||
const trackSubscriptionRenewed = useCallback((subscription = {}) => {
|
|
||||||
track(REVENUE_EVENTS.SUBSCRIPTION_RENEWED, {
|
|
||||||
plan_name: subscription.plan,
|
|
||||||
amount: subscription.amount,
|
|
||||||
previous_end_date: subscription.previousEndDate,
|
|
||||||
new_end_date: subscription.newEndDate,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSubscriptionEvents', '🔄 Subscription Renewed', {
|
|
||||||
plan: subscription.plan,
|
|
||||||
amount: subscription.amount,
|
|
||||||
});
|
|
||||||
}, [track]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪订阅取消
|
|
||||||
* @param {string} reason - 取消原因
|
|
||||||
* @param {boolean} cancelImmediately - 是否立即取消
|
|
||||||
*/
|
|
||||||
const trackSubscriptionCancelled = useCallback((reason = '', cancelImmediately = false) => {
|
|
||||||
track(REVENUE_EVENTS.SUBSCRIPTION_CANCELLED, {
|
|
||||||
plan_name: currentSubscription?.plan,
|
|
||||||
reason,
|
|
||||||
has_reason: Boolean(reason),
|
|
||||||
cancel_immediately: cancelImmediately,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSubscriptionEvents', '🚫 Subscription Cancelled', {
|
|
||||||
plan: currentSubscription?.plan,
|
|
||||||
reason,
|
|
||||||
cancelImmediately,
|
|
||||||
});
|
|
||||||
}, [track, currentSubscription]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 追踪优惠券应用
|
|
||||||
* @param {string} couponCode - 优惠券代码
|
|
||||||
* @param {number} discountAmount - 折扣金额
|
|
||||||
* @param {boolean} success - 是否成功
|
|
||||||
*/
|
|
||||||
const trackCouponApplied = useCallback((couponCode, discountAmount = 0, success = true) => {
|
|
||||||
if (!couponCode) {
|
|
||||||
logger.warn('useSubscriptionEvents', 'trackCouponApplied: couponCode is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
track('Coupon Applied', {
|
|
||||||
coupon_code: couponCode,
|
|
||||||
discount_amount: discountAmount,
|
|
||||||
success,
|
|
||||||
current_plan: currentSubscription?.plan || 'free',
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug('useSubscriptionEvents', success ? '🎟️ Coupon Applied' : '❌ Coupon Failed', {
|
|
||||||
couponCode,
|
|
||||||
discountAmount,
|
|
||||||
success,
|
|
||||||
});
|
|
||||||
}, [track, currentSubscription]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// 付费墙事件
|
|
||||||
trackPaywallShown,
|
|
||||||
trackPaywallDismissed,
|
|
||||||
trackUpgradePlanClicked,
|
|
||||||
|
|
||||||
// 订阅页面事件
|
|
||||||
trackSubscriptionPageViewed,
|
|
||||||
trackPricingPlanViewed,
|
|
||||||
trackPricingPlanSelected,
|
|
||||||
|
|
||||||
// 支付流程事件
|
|
||||||
trackPaymentPageViewed,
|
|
||||||
trackPaymentMethodSelected,
|
|
||||||
trackPaymentInitiated,
|
|
||||||
trackPaymentSuccessful,
|
|
||||||
trackPaymentFailed,
|
|
||||||
|
|
||||||
// 订阅管理事件
|
|
||||||
trackSubscriptionCreated,
|
|
||||||
trackSubscriptionRenewed,
|
|
||||||
trackSubscriptionCancelled,
|
|
||||||
|
|
||||||
// 优惠券事件
|
|
||||||
trackCouponApplied,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export default useSubscriptionEvents;
|
|
||||||
382
src/hooks/useSubscriptionEvents.ts
Normal file
382
src/hooks/useSubscriptionEvents.ts
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
// src/hooks/useSubscriptionEvents.ts
|
||||||
|
// 订阅和支付事件追踪 Hook
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { usePostHogTrack } from './usePostHogRedux';
|
||||||
|
import { RETENTION_EVENTS, REVENUE_EVENTS } from '../lib/constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前订阅信息
|
||||||
|
*/
|
||||||
|
interface SubscriptionInfo {
|
||||||
|
plan?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useSubscriptionEvents Hook 配置选项
|
||||||
|
*/
|
||||||
|
interface UseSubscriptionEventsOptions {
|
||||||
|
currentSubscription?: SubscriptionInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 支付信息
|
||||||
|
*/
|
||||||
|
interface PaymentInfo {
|
||||||
|
planName?: string;
|
||||||
|
paymentMethod?: string;
|
||||||
|
amount?: number;
|
||||||
|
billingCycle?: string;
|
||||||
|
orderId?: string;
|
||||||
|
transactionId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅信息
|
||||||
|
*/
|
||||||
|
interface SubscriptionData {
|
||||||
|
plan?: string;
|
||||||
|
billingCycle?: string;
|
||||||
|
amount?: number;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
previousEndDate?: string;
|
||||||
|
newEndDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useSubscriptionEvents Hook 返回值
|
||||||
|
*/
|
||||||
|
interface UseSubscriptionEventsReturn {
|
||||||
|
trackPaywallShown: (feature: string, requiredPlan?: string, triggerLocation?: string) => void;
|
||||||
|
trackPaywallDismissed: (feature: string, closeMethod?: string) => void;
|
||||||
|
trackUpgradePlanClicked: (targetPlan?: string, source?: string, feature?: string) => void;
|
||||||
|
trackSubscriptionPageViewed: (source?: string) => void;
|
||||||
|
trackPricingPlanViewed: (planName: string, price?: number) => void;
|
||||||
|
trackPricingPlanSelected: (planName: string, billingCycle?: string, price?: number) => void;
|
||||||
|
trackPaymentPageViewed: (planName: string, amount?: number) => void;
|
||||||
|
trackPaymentMethodSelected: (paymentMethod: string, amount?: number) => void;
|
||||||
|
trackPaymentInitiated: (paymentInfo?: PaymentInfo) => void;
|
||||||
|
trackPaymentSuccessful: (paymentInfo?: PaymentInfo) => void;
|
||||||
|
trackPaymentFailed: (paymentInfo?: PaymentInfo, errorReason?: string) => void;
|
||||||
|
trackSubscriptionCreated: (subscription?: SubscriptionData) => void;
|
||||||
|
trackSubscriptionRenewed: (subscription?: SubscriptionData) => void;
|
||||||
|
trackSubscriptionCancelled: (reason?: string, cancelImmediately?: boolean) => void;
|
||||||
|
trackCouponApplied: (couponCode: string, discountAmount?: number, success?: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅和支付事件追踪 Hook
|
||||||
|
* @param options - 配置选项
|
||||||
|
* @returns 事件追踪处理函数集合
|
||||||
|
*/
|
||||||
|
export const useSubscriptionEvents = ({
|
||||||
|
currentSubscription = null,
|
||||||
|
}: UseSubscriptionEventsOptions = {}): UseSubscriptionEventsReturn => {
|
||||||
|
const { track } = usePostHogTrack();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪付费墙展示
|
||||||
|
*/
|
||||||
|
const trackPaywallShown = useCallback(
|
||||||
|
(feature: string, requiredPlan: string = 'pro', triggerLocation: string = '') => {
|
||||||
|
if (!feature) {
|
||||||
|
console.warn('useSubscriptionEvents: trackPaywallShown - feature is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
track(REVENUE_EVENTS.PAYWALL_SHOWN, {
|
||||||
|
feature,
|
||||||
|
required_plan: requiredPlan,
|
||||||
|
current_plan: currentSubscription?.plan || 'free',
|
||||||
|
trigger_location: triggerLocation,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[track, currentSubscription]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪付费墙关闭
|
||||||
|
*/
|
||||||
|
const trackPaywallDismissed = useCallback(
|
||||||
|
(feature: string, closeMethod: string = 'dismiss') => {
|
||||||
|
if (!feature) {
|
||||||
|
console.warn('useSubscriptionEvents: trackPaywallDismissed - feature is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
track(REVENUE_EVENTS.PAYWALL_DISMISSED, {
|
||||||
|
feature,
|
||||||
|
close_method: closeMethod,
|
||||||
|
current_plan: currentSubscription?.plan || 'free',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[track, currentSubscription]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪升级按钮点击
|
||||||
|
*/
|
||||||
|
const trackUpgradePlanClicked = useCallback(
|
||||||
|
(targetPlan: string = 'pro', source: string = '', feature: string = '') => {
|
||||||
|
track(REVENUE_EVENTS.PAYWALL_UPGRADE_CLICKED, {
|
||||||
|
current_plan: currentSubscription?.plan || 'free',
|
||||||
|
target_plan: targetPlan,
|
||||||
|
source,
|
||||||
|
feature: feature || null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[track, currentSubscription]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪订阅页面查看
|
||||||
|
*/
|
||||||
|
const trackSubscriptionPageViewed = useCallback(
|
||||||
|
(source: string = '') => {
|
||||||
|
track(RETENTION_EVENTS.SUBSCRIPTION_PAGE_VIEWED, {
|
||||||
|
current_plan: currentSubscription?.plan || 'free',
|
||||||
|
subscription_status: currentSubscription?.status || 'unknown',
|
||||||
|
is_paid_user: currentSubscription?.plan && currentSubscription.plan !== 'free',
|
||||||
|
source,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[track, currentSubscription]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪定价计划查看
|
||||||
|
*/
|
||||||
|
const trackPricingPlanViewed = useCallback(
|
||||||
|
(planName: string, price: number = 0) => {
|
||||||
|
if (!planName) {
|
||||||
|
console.warn('useSubscriptionEvents: trackPricingPlanViewed - planName is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
track('Pricing Plan Viewed', {
|
||||||
|
plan_name: planName,
|
||||||
|
price,
|
||||||
|
current_plan: currentSubscription?.plan || 'free',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[track, currentSubscription]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪定价计划选择
|
||||||
|
*/
|
||||||
|
const trackPricingPlanSelected = useCallback(
|
||||||
|
(planName: string, billingCycle: string = 'monthly', price: number = 0) => {
|
||||||
|
if (!planName) {
|
||||||
|
console.warn('useSubscriptionEvents: trackPricingPlanSelected - planName is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
track('Pricing Plan Selected', {
|
||||||
|
plan_name: planName,
|
||||||
|
billing_cycle: billingCycle,
|
||||||
|
price,
|
||||||
|
current_plan: currentSubscription?.plan || 'free',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[track, currentSubscription]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪支付页面查看
|
||||||
|
*/
|
||||||
|
const trackPaymentPageViewed = useCallback(
|
||||||
|
(planName: string, amount: number = 0) => {
|
||||||
|
track(REVENUE_EVENTS.PAYMENT_PAGE_VIEWED, {
|
||||||
|
plan_name: planName,
|
||||||
|
amount,
|
||||||
|
current_plan: currentSubscription?.plan || 'free',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[track, currentSubscription]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪支付方式选择
|
||||||
|
*/
|
||||||
|
const trackPaymentMethodSelected = useCallback(
|
||||||
|
(paymentMethod: string, amount: number = 0) => {
|
||||||
|
if (!paymentMethod) {
|
||||||
|
console.warn('useSubscriptionEvents: trackPaymentMethodSelected - paymentMethod is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
track(REVENUE_EVENTS.PAYMENT_METHOD_SELECTED, {
|
||||||
|
payment_method: paymentMethod,
|
||||||
|
amount,
|
||||||
|
current_plan: currentSubscription?.plan || 'free',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[track, currentSubscription]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪支付发起
|
||||||
|
*/
|
||||||
|
const trackPaymentInitiated = useCallback(
|
||||||
|
(paymentInfo: PaymentInfo = {}) => {
|
||||||
|
track(REVENUE_EVENTS.PAYMENT_INITIATED, {
|
||||||
|
plan_name: paymentInfo.planName,
|
||||||
|
payment_method: paymentInfo.paymentMethod,
|
||||||
|
amount: paymentInfo.amount,
|
||||||
|
billing_cycle: paymentInfo.billingCycle,
|
||||||
|
order_id: paymentInfo.orderId,
|
||||||
|
current_plan: currentSubscription?.plan || 'free',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[track, currentSubscription]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪支付成功
|
||||||
|
*/
|
||||||
|
const trackPaymentSuccessful = useCallback(
|
||||||
|
(paymentInfo: PaymentInfo = {}) => {
|
||||||
|
track(REVENUE_EVENTS.PAYMENT_SUCCESSFUL, {
|
||||||
|
plan_name: paymentInfo.planName,
|
||||||
|
payment_method: paymentInfo.paymentMethod,
|
||||||
|
amount: paymentInfo.amount,
|
||||||
|
billing_cycle: paymentInfo.billingCycle,
|
||||||
|
order_id: paymentInfo.orderId,
|
||||||
|
transaction_id: paymentInfo.transactionId,
|
||||||
|
previous_plan: currentSubscription?.plan || 'free',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[track, currentSubscription]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪支付失败
|
||||||
|
*/
|
||||||
|
const trackPaymentFailed = useCallback(
|
||||||
|
(paymentInfo: PaymentInfo = {}, errorReason: string = '') => {
|
||||||
|
track(REVENUE_EVENTS.PAYMENT_FAILED, {
|
||||||
|
plan_name: paymentInfo.planName,
|
||||||
|
payment_method: paymentInfo.paymentMethod,
|
||||||
|
amount: paymentInfo.amount,
|
||||||
|
error_reason: errorReason,
|
||||||
|
order_id: paymentInfo.orderId,
|
||||||
|
current_plan: currentSubscription?.plan || 'free',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[track, currentSubscription]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪订阅创建成功
|
||||||
|
*/
|
||||||
|
const trackSubscriptionCreated = useCallback(
|
||||||
|
(subscription: SubscriptionData = {}) => {
|
||||||
|
track(REVENUE_EVENTS.SUBSCRIPTION_CREATED, {
|
||||||
|
plan_name: subscription.plan,
|
||||||
|
billing_cycle: subscription.billingCycle,
|
||||||
|
amount: subscription.amount,
|
||||||
|
start_date: subscription.startDate,
|
||||||
|
end_date: subscription.endDate,
|
||||||
|
previous_plan: currentSubscription?.plan || 'free',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[track, currentSubscription]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪订阅续费
|
||||||
|
*/
|
||||||
|
const trackSubscriptionRenewed = useCallback(
|
||||||
|
(subscription: SubscriptionData = {}) => {
|
||||||
|
track(REVENUE_EVENTS.SUBSCRIPTION_RENEWED, {
|
||||||
|
plan_name: subscription.plan,
|
||||||
|
amount: subscription.amount,
|
||||||
|
previous_end_date: subscription.previousEndDate,
|
||||||
|
new_end_date: subscription.newEndDate,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[track]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪订阅取消
|
||||||
|
*/
|
||||||
|
const trackSubscriptionCancelled = useCallback(
|
||||||
|
(reason: string = '', cancelImmediately: boolean = false) => {
|
||||||
|
track(REVENUE_EVENTS.SUBSCRIPTION_CANCELLED, {
|
||||||
|
plan_name: currentSubscription?.plan,
|
||||||
|
reason,
|
||||||
|
has_reason: Boolean(reason),
|
||||||
|
cancel_immediately: cancelImmediately,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[track, currentSubscription]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪优惠券应用
|
||||||
|
*/
|
||||||
|
const trackCouponApplied = useCallback(
|
||||||
|
(couponCode: string, discountAmount: number = 0, success: boolean = true) => {
|
||||||
|
if (!couponCode) {
|
||||||
|
console.warn('useSubscriptionEvents: trackCouponApplied - couponCode is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
track('Coupon Applied', {
|
||||||
|
coupon_code: couponCode,
|
||||||
|
discount_amount: discountAmount,
|
||||||
|
success,
|
||||||
|
current_plan: currentSubscription?.plan || 'free',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[track, currentSubscription]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 付费墙事件
|
||||||
|
trackPaywallShown,
|
||||||
|
trackPaywallDismissed,
|
||||||
|
trackUpgradePlanClicked,
|
||||||
|
|
||||||
|
// 订阅页面事件
|
||||||
|
trackSubscriptionPageViewed,
|
||||||
|
trackPricingPlanViewed,
|
||||||
|
trackPricingPlanSelected,
|
||||||
|
|
||||||
|
// 支付流程事件
|
||||||
|
trackPaymentPageViewed,
|
||||||
|
trackPaymentMethodSelected,
|
||||||
|
trackPaymentInitiated,
|
||||||
|
trackPaymentSuccessful,
|
||||||
|
trackPaymentFailed,
|
||||||
|
|
||||||
|
// 订阅管理事件
|
||||||
|
trackSubscriptionCreated,
|
||||||
|
trackSubscriptionRenewed,
|
||||||
|
trackSubscriptionCancelled,
|
||||||
|
|
||||||
|
// 优惠券事件
|
||||||
|
trackCouponApplied,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useSubscriptionEvents;
|
||||||
44
src/types/static-assets.d.ts
vendored
Normal file
44
src/types/static-assets.d.ts
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* 静态资源模块声明
|
||||||
|
* 允许 TypeScript 正确处理静态资源导入
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare module '*.png' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.jpg' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.jpeg' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.gif' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.svg' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.webp' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.ico' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '*.bmp' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
@@ -44,8 +44,8 @@ import {
|
|||||||
import FullCalendar from '@fullcalendar/react';
|
import FullCalendar from '@fullcalendar/react';
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||||
import interactionPlugin from '@fullcalendar/interaction';
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
import { DateClickArg } from '@fullcalendar/interaction';
|
import type { DateClickArg } from '@fullcalendar/interaction';
|
||||||
import { EventClickArg } from '@fullcalendar/common';
|
import type { EventClickArg } from '@fullcalendar/core';
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
import 'dayjs/locale/zh-cn';
|
import 'dayjs/locale/zh-cn';
|
||||||
|
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export const PlansPanel: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 渲染单个卡片
|
// 渲染单个卡片
|
||||||
const renderCard = (item: InvestmentEvent): JSX.Element => {
|
const renderCard = (item: InvestmentEvent): React.ReactElement => {
|
||||||
const statusInfo = getStatusInfo(item.status);
|
const statusInfo = getStatusInfo(item.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ export const ReviewsPanel: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 渲染单个卡片
|
// 渲染单个卡片
|
||||||
const renderCard = (item: InvestmentEvent): JSX.Element => {
|
const renderCard = (item: InvestmentEvent): React.ReactElement => {
|
||||||
const statusInfo = getStatusInfo(item.status);
|
const statusInfo = getStatusInfo(item.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -78,14 +78,13 @@ const KLineChartView: React.FC<KLineChartViewProps> = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
candle: {
|
candle: {
|
||||||
type: 'line', // 使用折线图模式
|
type: 'area' as const, // 使用面积图模式
|
||||||
line: {
|
area: {
|
||||||
upColor: themeColors.primary.gold,
|
lineColor: themeColors.primary.gold,
|
||||||
downColor: themeColors.primary.gold,
|
lineSize: 2,
|
||||||
style: 'solid',
|
backgroundColor: [`${themeColors.primary.gold}30`, `${themeColors.primary.gold}05`],
|
||||||
size: 2,
|
|
||||||
},
|
},
|
||||||
},
|
} as any,
|
||||||
crosshair: {
|
crosshair: {
|
||||||
horizontal: {
|
horizontal: {
|
||||||
line: {
|
line: {
|
||||||
@@ -148,7 +147,7 @@ const KLineChartView: React.FC<KLineChartViewProps> = ({
|
|||||||
.sort((a, b) => a.timestamp - b.timestamp);
|
.sort((a, b) => a.timestamp - b.timestamp);
|
||||||
|
|
||||||
// 设置数据
|
// 设置数据
|
||||||
chart?.applyNewData(chartData);
|
(chart as any)?.applyNewData(chartData);
|
||||||
|
|
||||||
chartRef.current = chart;
|
chartRef.current = chart;
|
||||||
|
|
||||||
|
|||||||
@@ -1,508 +0,0 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
VStack,
|
|
||||||
HStack,
|
|
||||||
Text,
|
|
||||||
Button,
|
|
||||||
ButtonGroup,
|
|
||||||
Flex,
|
|
||||||
Icon,
|
|
||||||
useColorMode,
|
|
||||||
Tooltip,
|
|
||||||
} from '@chakra-ui/react';
|
|
||||||
import { createChart, LineSeries } from 'lightweight-charts';
|
|
||||||
import type { IChartApi, ISeriesApi, LineData, Time } from 'lightweight-charts';
|
|
||||||
import {
|
|
||||||
FaExpand,
|
|
||||||
FaCompress,
|
|
||||||
FaCamera,
|
|
||||||
FaRedo,
|
|
||||||
FaCog,
|
|
||||||
} from 'react-icons/fa';
|
|
||||||
import { MetricDataPoint } from '@services/categoryService';
|
|
||||||
|
|
||||||
// 黑金主题配色
|
|
||||||
const themeColors = {
|
|
||||||
bg: {
|
|
||||||
primary: '#0a0a0a',
|
|
||||||
secondary: '#1a1a1a',
|
|
||||||
card: '#1e1e1e',
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
primary: '#ffffff',
|
|
||||||
secondary: '#b8b8b8',
|
|
||||||
muted: '#808080',
|
|
||||||
gold: '#D4AF37',
|
|
||||||
},
|
|
||||||
border: {
|
|
||||||
default: 'rgba(255, 255, 255, 0.1)',
|
|
||||||
gold: 'rgba(212, 175, 55, 0.3)',
|
|
||||||
},
|
|
||||||
primary: {
|
|
||||||
gold: '#D4AF37',
|
|
||||||
goldLight: '#F4E3A7',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TradingViewChartProps {
|
|
||||||
data: MetricDataPoint[];
|
|
||||||
metricName: string;
|
|
||||||
unit: string;
|
|
||||||
frequency: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TimeRange = '1M' | '3M' | '6M' | '1Y' | 'YTD' | 'ALL';
|
|
||||||
|
|
||||||
const TradingViewChart: React.FC<TradingViewChartProps> = ({
|
|
||||||
data,
|
|
||||||
metricName,
|
|
||||||
unit,
|
|
||||||
frequency,
|
|
||||||
}) => {
|
|
||||||
const chartContainerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const chartRef = useRef<IChartApi | null>(null);
|
|
||||||
const lineSeriesRef = useRef<ISeriesApi<'Line'> | null>(null);
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
|
||||||
const [selectedRange, setSelectedRange] = useState<TimeRange>('ALL');
|
|
||||||
const { colorMode } = useColorMode();
|
|
||||||
|
|
||||||
// 初始化图表
|
|
||||||
useEffect(() => {
|
|
||||||
if (!chartContainerRef.current || data.length === 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 创建图表 (lightweight-charts 5.0 标准 API)
|
|
||||||
const chart = createChart(chartContainerRef.current, {
|
|
||||||
width: chartContainerRef.current.clientWidth,
|
|
||||||
height: 500,
|
|
||||||
layout: {
|
|
||||||
background: { type: 'solid', color: themeColors.bg.card },
|
|
||||||
textColor: themeColors.text.secondary,
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
vertLines: {
|
|
||||||
color: 'rgba(255, 255, 255, 0.05)',
|
|
||||||
},
|
|
||||||
horzLines: {
|
|
||||||
color: 'rgba(255, 255, 255, 0.05)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
crosshair: {
|
|
||||||
vertLine: {
|
|
||||||
color: themeColors.primary.gold,
|
|
||||||
width: 1,
|
|
||||||
style: 3, // 虚线
|
|
||||||
labelBackgroundColor: themeColors.primary.gold,
|
|
||||||
},
|
|
||||||
horzLine: {
|
|
||||||
color: themeColors.primary.gold,
|
|
||||||
width: 1,
|
|
||||||
style: 3,
|
|
||||||
labelBackgroundColor: themeColors.primary.gold,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rightPriceScale: {
|
|
||||||
borderColor: themeColors.border.default,
|
|
||||||
},
|
|
||||||
timeScale: {
|
|
||||||
borderColor: themeColors.border.default,
|
|
||||||
timeVisible: true,
|
|
||||||
secondsVisible: false,
|
|
||||||
rightOffset: 12,
|
|
||||||
barSpacing: 6, // 增加条形间距,减少拥挤
|
|
||||||
fixLeftEdge: false,
|
|
||||||
lockVisibleTimeRangeOnResize: true,
|
|
||||||
rightBarStaysOnScroll: true,
|
|
||||||
borderVisible: true,
|
|
||||||
visible: true,
|
|
||||||
// 控制时间标签的最小间距(像素)
|
|
||||||
tickMarkMaxCharacterLength: 8,
|
|
||||||
},
|
|
||||||
localization: {
|
|
||||||
locale: 'en-US',
|
|
||||||
// 使用 ISO 日期格式,强制显示 YYYY-MM-DD
|
|
||||||
dateFormat: 'dd MMM \'yy', // 这会被我们的自定义格式化器覆盖
|
|
||||||
},
|
|
||||||
handleScroll: {
|
|
||||||
mouseWheel: true,
|
|
||||||
pressedMouseMove: true,
|
|
||||||
},
|
|
||||||
handleScale: {
|
|
||||||
axisPressedMouseMove: true,
|
|
||||||
mouseWheel: true,
|
|
||||||
pinch: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 设置时间轴的自定义格式化器(强制显示 YYYY-MM-DD)
|
|
||||||
chart.applyOptions({
|
|
||||||
localization: {
|
|
||||||
timeFormatter: (time) => {
|
|
||||||
// time 可能是字符串 'YYYY-MM-DD' 或时间戳
|
|
||||||
if (typeof time === 'string') {
|
|
||||||
return time; // 直接返回 YYYY-MM-DD 字符串
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是时间戳,转换为 YYYY-MM-DD
|
|
||||||
const date = new Date(time * 1000);
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 创建折线系列 (lightweight-charts 5.0 使用 addSeries 方法)
|
|
||||||
// 第一个参数是 series 类本身(不是实例)
|
|
||||||
const lineSeries = chart.addSeries(LineSeries, {
|
|
||||||
color: themeColors.primary.gold,
|
|
||||||
lineWidth: 2,
|
|
||||||
crosshairMarkerVisible: true,
|
|
||||||
crosshairMarkerRadius: 6,
|
|
||||||
crosshairMarkerBorderColor: themeColors.primary.goldLight,
|
|
||||||
crosshairMarkerBackgroundColor: themeColors.primary.gold,
|
|
||||||
lastValueVisible: true,
|
|
||||||
priceLineVisible: true,
|
|
||||||
priceLineColor: themeColors.primary.gold,
|
|
||||||
priceLineWidth: 1,
|
|
||||||
priceLineStyle: 3, // 虚线
|
|
||||||
title: metricName,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 转换数据格式
|
|
||||||
// lightweight-charts 5.0 需要 YYYY-MM-DD 格式的字符串作为 time
|
|
||||||
const chartData: LineData[] = data
|
|
||||||
.filter((item) => item.value !== null)
|
|
||||||
.map((item) => {
|
|
||||||
// 确保日期格式为 YYYY-MM-DD
|
|
||||||
const dateStr = item.date.trim();
|
|
||||||
return {
|
|
||||||
time: dateStr as Time,
|
|
||||||
value: item.value as number,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => {
|
|
||||||
// 确保时间从左到右递增
|
|
||||||
const timeA = new Date(a.time as string).getTime();
|
|
||||||
const timeB = new Date(b.time as string).getTime();
|
|
||||||
return timeA - timeB;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 设置数据
|
|
||||||
lineSeries.setData(chartData);
|
|
||||||
|
|
||||||
// 自动缩放到合适的视图
|
|
||||||
chart.timeScale().fitContent();
|
|
||||||
|
|
||||||
chartRef.current = chart;
|
|
||||||
lineSeriesRef.current = lineSeries;
|
|
||||||
|
|
||||||
// 响应式调整
|
|
||||||
const handleResize = () => {
|
|
||||||
if (chartContainerRef.current && chart) {
|
|
||||||
chart.applyOptions({
|
|
||||||
width: chartContainerRef.current.clientWidth,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
chart.remove();
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ TradingView Chart 初始化失败:', error);
|
|
||||||
console.error('Error details:', {
|
|
||||||
message: error.message,
|
|
||||||
stack: error.stack,
|
|
||||||
createChartType: typeof createChart,
|
|
||||||
LineSeriesType: typeof LineSeries,
|
|
||||||
});
|
|
||||||
// 重新抛出错误让 ErrorBoundary 捕获
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, [data, metricName]);
|
|
||||||
|
|
||||||
// 时间范围筛选
|
|
||||||
const handleTimeRangeChange = (range: TimeRange) => {
|
|
||||||
setSelectedRange(range);
|
|
||||||
|
|
||||||
if (!chartRef.current || data.length === 0) return;
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
let startDate: Date;
|
|
||||||
|
|
||||||
switch (range) {
|
|
||||||
case '1M':
|
|
||||||
startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
|
|
||||||
break;
|
|
||||||
case '3M':
|
|
||||||
startDate = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
|
|
||||||
break;
|
|
||||||
case '6M':
|
|
||||||
startDate = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate());
|
|
||||||
break;
|
|
||||||
case '1Y':
|
|
||||||
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
|
|
||||||
break;
|
|
||||||
case 'YTD':
|
|
||||||
startDate = new Date(now.getFullYear(), 0, 1); // 当年1月1日
|
|
||||||
break;
|
|
||||||
case 'ALL':
|
|
||||||
default:
|
|
||||||
chartRef.current.timeScale().fitContent();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置可见范围
|
|
||||||
const startTimestamp = startDate.getTime() / 1000;
|
|
||||||
const endTimestamp = now.getTime() / 1000;
|
|
||||||
|
|
||||||
chartRef.current.timeScale().setVisibleRange({
|
|
||||||
from: startTimestamp as Time,
|
|
||||||
to: endTimestamp as Time,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 重置缩放
|
|
||||||
const handleReset = () => {
|
|
||||||
if (chartRef.current) {
|
|
||||||
chartRef.current.timeScale().fitContent();
|
|
||||||
setSelectedRange('ALL');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 截图功能
|
|
||||||
const handleScreenshot = () => {
|
|
||||||
if (!chartRef.current) return;
|
|
||||||
|
|
||||||
const canvas = chartContainerRef.current?.querySelector('canvas');
|
|
||||||
if (!canvas) return;
|
|
||||||
|
|
||||||
canvas.toBlob((blob) => {
|
|
||||||
if (!blob) return;
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `${metricName}_${new Date().toISOString().split('T')[0]}.png`;
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 全屏切换
|
|
||||||
const toggleFullscreen = () => {
|
|
||||||
if (!chartContainerRef.current) return;
|
|
||||||
|
|
||||||
if (!isFullscreen) {
|
|
||||||
if (chartContainerRef.current.requestFullscreen) {
|
|
||||||
chartContainerRef.current.requestFullscreen();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (document.exitFullscreen) {
|
|
||||||
document.exitFullscreen();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIsFullscreen(!isFullscreen);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 计算统计数据
|
|
||||||
const stats = React.useMemo(() => {
|
|
||||||
const values = data.filter((item) => item.value !== null).map((item) => item.value as number);
|
|
||||||
|
|
||||||
if (values.length === 0) {
|
|
||||||
return { min: 0, max: 0, avg: 0, latest: 0, change: 0, changePercent: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const min = Math.min(...values);
|
|
||||||
const max = Math.max(...values);
|
|
||||||
const avg = values.reduce((sum, val) => sum + val, 0) / values.length;
|
|
||||||
const latest = values[values.length - 1];
|
|
||||||
const first = values[0];
|
|
||||||
const change = latest - first;
|
|
||||||
const changePercent = first !== 0 ? (change / first) * 100 : 0;
|
|
||||||
|
|
||||||
return { min, max, avg, latest, change, changePercent };
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
// 格式化数字
|
|
||||||
const formatNumber = (num: number) => {
|
|
||||||
if (Math.abs(num) >= 1e9) {
|
|
||||||
return (num / 1e9).toFixed(2) + 'B';
|
|
||||||
}
|
|
||||||
if (Math.abs(num) >= 1e6) {
|
|
||||||
return (num / 1e6).toFixed(2) + 'M';
|
|
||||||
}
|
|
||||||
if (Math.abs(num) >= 1e3) {
|
|
||||||
return (num / 1e3).toFixed(2) + 'K';
|
|
||||||
}
|
|
||||||
return num.toFixed(2);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VStack align="stretch" spacing={4} w="100%">
|
|
||||||
{/* 工具栏 */}
|
|
||||||
<Flex justify="space-between" align="center" wrap="wrap" gap={4}>
|
|
||||||
{/* 时间范围选择 */}
|
|
||||||
<ButtonGroup size="sm" isAttached variant="outline">
|
|
||||||
{(['1M', '3M', '6M', '1Y', 'YTD', 'ALL'] as TimeRange[]).map((range) => (
|
|
||||||
<Button
|
|
||||||
key={range}
|
|
||||||
onClick={() => handleTimeRangeChange(range)}
|
|
||||||
bg={selectedRange === range ? themeColors.primary.gold : 'transparent'}
|
|
||||||
color={
|
|
||||||
selectedRange === range ? themeColors.bg.primary : themeColors.text.secondary
|
|
||||||
}
|
|
||||||
borderColor={themeColors.border.gold}
|
|
||||||
_hover={{
|
|
||||||
bg: selectedRange === range ? themeColors.primary.goldLight : themeColors.bg.card,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{range}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</ButtonGroup>
|
|
||||||
|
|
||||||
{/* 图表操作 */}
|
|
||||||
<HStack spacing={2}>
|
|
||||||
<Tooltip label="重置视图">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
color={themeColors.text.secondary}
|
|
||||||
_hover={{ color: themeColors.primary.gold }}
|
|
||||||
onClick={handleReset}
|
|
||||||
>
|
|
||||||
<Icon as={FaRedo} />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label="截图">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
color={themeColors.text.secondary}
|
|
||||||
_hover={{ color: themeColors.primary.gold }}
|
|
||||||
onClick={handleScreenshot}
|
|
||||||
>
|
|
||||||
<Icon as={FaCamera} />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'}>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
color={themeColors.text.secondary}
|
|
||||||
_hover={{ color: themeColors.primary.gold }}
|
|
||||||
onClick={toggleFullscreen}
|
|
||||||
>
|
|
||||||
<Icon as={isFullscreen ? FaCompress : FaExpand} />
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
</HStack>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{/* 统计数据 */}
|
|
||||||
<Flex
|
|
||||||
justify="space-around"
|
|
||||||
align="center"
|
|
||||||
bg={themeColors.bg.secondary}
|
|
||||||
p={3}
|
|
||||||
borderRadius="md"
|
|
||||||
borderWidth="1px"
|
|
||||||
borderColor={themeColors.border.default}
|
|
||||||
wrap="wrap"
|
|
||||||
gap={4}
|
|
||||||
>
|
|
||||||
<VStack spacing={0}>
|
|
||||||
<Text color={themeColors.text.muted} fontSize="xs">
|
|
||||||
最新值
|
|
||||||
</Text>
|
|
||||||
<Text color={themeColors.text.gold} fontSize="lg" fontWeight="bold">
|
|
||||||
{formatNumber(stats.latest)} {unit}
|
|
||||||
</Text>
|
|
||||||
<Text
|
|
||||||
color={stats.change >= 0 ? '#00ff88' : '#ff4444'}
|
|
||||||
fontSize="xs"
|
|
||||||
fontWeight="bold"
|
|
||||||
>
|
|
||||||
{stats.change >= 0 ? '+' : ''}
|
|
||||||
{formatNumber(stats.change)} ({stats.changePercent.toFixed(2)}%)
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<VStack spacing={0}>
|
|
||||||
<Text color={themeColors.text.muted} fontSize="xs">
|
|
||||||
平均值
|
|
||||||
</Text>
|
|
||||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
|
||||||
{formatNumber(stats.avg)} {unit}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<VStack spacing={0}>
|
|
||||||
<Text color={themeColors.text.muted} fontSize="xs">
|
|
||||||
最高值
|
|
||||||
</Text>
|
|
||||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
|
||||||
{formatNumber(stats.max)} {unit}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<VStack spacing={0}>
|
|
||||||
<Text color={themeColors.text.muted} fontSize="xs">
|
|
||||||
最低值
|
|
||||||
</Text>
|
|
||||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
|
||||||
{formatNumber(stats.min)} {unit}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<VStack spacing={0}>
|
|
||||||
<Text color={themeColors.text.muted} fontSize="xs">
|
|
||||||
数据点数
|
|
||||||
</Text>
|
|
||||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
|
||||||
{data.filter((item) => item.value !== null).length}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
|
|
||||||
<VStack spacing={0}>
|
|
||||||
<Text color={themeColors.text.muted} fontSize="xs">
|
|
||||||
频率
|
|
||||||
</Text>
|
|
||||||
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
|
|
||||||
{frequency}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</Flex>
|
|
||||||
|
|
||||||
{/* 图表容器 */}
|
|
||||||
<Box
|
|
||||||
ref={chartContainerRef}
|
|
||||||
w="100%"
|
|
||||||
h="500px"
|
|
||||||
borderRadius="md"
|
|
||||||
borderWidth="1px"
|
|
||||||
borderColor={themeColors.border.gold}
|
|
||||||
overflow="hidden"
|
|
||||||
position="relative"
|
|
||||||
bg={themeColors.bg.card}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 提示信息 */}
|
|
||||||
<Flex justify="space-between" align="center" fontSize="xs" color={themeColors.text.muted}>
|
|
||||||
<HStack spacing={4}>
|
|
||||||
<Text>💡 提示:滚动鼠标滚轮缩放,拖拽移动视图</Text>
|
|
||||||
</HStack>
|
|
||||||
<Text>数据来源: {metricName}</Text>
|
|
||||||
</Flex>
|
|
||||||
</VStack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TradingViewChart;
|
|
||||||
@@ -696,18 +696,20 @@ const DataBrowser: React.FC = () => {
|
|||||||
p={3}
|
p={3}
|
||||||
cursor="pointer"
|
cursor="pointer"
|
||||||
bg="transparent"
|
bg="transparent"
|
||||||
_hover={{ bg: themeColors.bg.cardHover }}
|
_hover={{
|
||||||
|
bg: themeColors.bg.cardHover,
|
||||||
|
borderLeftColor: themeColors.primary.gold,
|
||||||
|
}}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
borderLeftWidth="3px"
|
borderLeftWidth="3px"
|
||||||
borderLeftColor="transparent"
|
borderLeftColor="transparent"
|
||||||
_hover={{ borderLeftColor: themeColors.primary.gold }}
|
|
||||||
transition="all 0.2s"
|
transition="all 0.2s"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// 转换搜索结果为 TreeMetric 格式
|
// 转换搜索结果为 TreeMetric 格式
|
||||||
const metric: TreeMetric = {
|
const metric: TreeMetric = {
|
||||||
metric_id: result.metric_id,
|
metric_id: result.metric_id,
|
||||||
metric_name: result.metric_name,
|
metric_name: result.metric_name,
|
||||||
source: result.source,
|
source: result.source as 'SMM' | 'Mysteel',
|
||||||
frequency: result.frequency,
|
frequency: result.frequency,
|
||||||
unit: result.unit,
|
unit: result.unit,
|
||||||
description: result.description,
|
description: result.description,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import Button from "@/components/Button2";
|
import Button from "@/components/Button";
|
||||||
|
|
||||||
import { pricing } from "./content";
|
import { pricing } from "./content";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user