Update
This commit is contained in:
@@ -5,7 +5,9 @@ import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import DetailedErrorFallback from './components/custom/detailed-error-fallback'; // Import your component
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
import { initializeTheme } from './hooks/use-appearance';
|
||||
import AuthDialog from './modules/auth/AuthDialog';
|
||||
import { AxiosProvider } from './plugins/AxiosContext';
|
||||
import { MittProvider } from './plugins/MittContext';
|
||||
|
||||
@@ -34,6 +36,8 @@ createInertiaApp({
|
||||
>
|
||||
<MittProvider>
|
||||
<AxiosProvider>
|
||||
<Toaster position="top-right" />
|
||||
<AuthDialog />
|
||||
<App {...props} />
|
||||
</AxiosProvider>
|
||||
</MittProvider>
|
||||
|
||||
46
resources/js/components/magicui/pulsating-button.tsx
Normal file
46
resources/js/components/magicui/pulsating-button.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PulsatingButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
pulseColor?: string;
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
export const PulsatingButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
PulsatingButtonProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
pulseColor = "#808080",
|
||||
duration = "1.5s",
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer items-center justify-center rounded-lg bg-primary px-4 py-2 text-center text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--pulse-color": pulseColor,
|
||||
"--duration": duration,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative z-10">{children}</div>
|
||||
<div className="absolute left-1/2 top-1/2 size-full -translate-x-1/2 -translate-y-1/2 animate-pulse rounded-lg bg-inherit" />
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PulsatingButton.displayName = "PulsatingButton";
|
||||
150
resources/js/components/magicui/sparkles-text.tsx
Normal file
150
resources/js/components/magicui/sparkles-text.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { CSSProperties, ReactElement, useEffect, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Sparkle {
|
||||
id: string;
|
||||
x: string;
|
||||
y: string;
|
||||
color: string;
|
||||
delay: number;
|
||||
scale: number;
|
||||
lifespan: number;
|
||||
}
|
||||
|
||||
const Sparkle: React.FC<Sparkle> = ({ id, x, y, color, delay, scale }) => {
|
||||
return (
|
||||
<motion.svg
|
||||
key={id}
|
||||
className="pointer-events-none absolute z-20"
|
||||
initial={{ opacity: 0, left: x, top: y }}
|
||||
animate={{
|
||||
opacity: [0, 1, 0],
|
||||
scale: [0, scale, 0],
|
||||
rotate: [75, 120, 150],
|
||||
}}
|
||||
transition={{ duration: 0.8, repeat: Infinity, delay }}
|
||||
width="21"
|
||||
height="21"
|
||||
viewBox="0 0 21 21"
|
||||
>
|
||||
<path
|
||||
d="M9.82531 0.843845C10.0553 0.215178 10.9446 0.215178 11.1746 0.843845L11.8618 2.72026C12.4006 4.19229 12.3916 6.39157 13.5 7.5C14.6084 8.60843 16.8077 8.59935 18.2797 9.13822L20.1561 9.82534C20.7858 10.0553 20.7858 10.9447 20.1561 11.1747L18.2797 11.8618C16.8077 12.4007 14.6084 12.3916 13.5 13.5C12.3916 14.6084 12.4006 16.8077 11.8618 18.2798L11.1746 20.1562C10.9446 20.7858 10.0553 20.7858 9.82531 20.1562L9.13819 18.2798C8.59932 16.8077 8.60843 14.6084 7.5 13.5C6.39157 12.3916 4.19225 12.4007 2.72023 11.8618L0.843814 11.1747C0.215148 10.9447 0.215148 10.0553 0.843814 9.82534L2.72023 9.13822C4.19225 8.59935 6.39157 8.60843 7.5 7.5C8.60843 6.39157 8.59932 4.19229 9.13819 2.72026L9.82531 0.843845Z"
|
||||
fill={color}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
};
|
||||
|
||||
interface SparklesTextProps {
|
||||
/**
|
||||
* @default <div />
|
||||
* @type ReactElement
|
||||
* @description
|
||||
* The component to be rendered as the text
|
||||
* */
|
||||
as?: ReactElement;
|
||||
|
||||
/**
|
||||
* @default ""
|
||||
* @type string
|
||||
* @description
|
||||
* The className of the text
|
||||
*/
|
||||
className?: string;
|
||||
|
||||
/**
|
||||
* @required
|
||||
* @type ReactNode
|
||||
* @description
|
||||
* The content to be displayed
|
||||
* */
|
||||
children: React.ReactNode;
|
||||
|
||||
/**
|
||||
* @default 10
|
||||
* @type number
|
||||
* @description
|
||||
* The count of sparkles
|
||||
* */
|
||||
sparklesCount?: number;
|
||||
|
||||
/**
|
||||
* @default "{first: '#9E7AFF', second: '#FE8BBB'}"
|
||||
* @type string
|
||||
* @description
|
||||
* The colors of the sparkles
|
||||
* */
|
||||
colors?: {
|
||||
first: string;
|
||||
second: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const SparklesText: React.FC<SparklesTextProps> = ({
|
||||
children,
|
||||
colors = { first: "#9E7AFF", second: "#FE8BBB" },
|
||||
className,
|
||||
sparklesCount = 10,
|
||||
...props
|
||||
}) => {
|
||||
const [sparkles, setSparkles] = useState<Sparkle[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const generateStar = (): Sparkle => {
|
||||
const starX = `${Math.random() * 100}%`;
|
||||
const starY = `${Math.random() * 100}%`;
|
||||
const color = Math.random() > 0.5 ? colors.first : colors.second;
|
||||
const delay = Math.random() * 2;
|
||||
const scale = Math.random() * 1 + 0.3;
|
||||
const lifespan = Math.random() * 10 + 5;
|
||||
const id = `${starX}-${starY}-${Date.now()}`;
|
||||
return { id, x: starX, y: starY, color, delay, scale, lifespan };
|
||||
};
|
||||
|
||||
const initializeStars = () => {
|
||||
const newSparkles = Array.from({ length: sparklesCount }, generateStar);
|
||||
setSparkles(newSparkles);
|
||||
};
|
||||
|
||||
const updateStars = () => {
|
||||
setSparkles((currentSparkles) =>
|
||||
currentSparkles.map((star) => {
|
||||
if (star.lifespan <= 0) {
|
||||
return generateStar();
|
||||
} else {
|
||||
return { ...star, lifespan: star.lifespan - 0.1 };
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
initializeStars();
|
||||
const interval = setInterval(updateStars, 100);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [colors.first, colors.second, sparklesCount]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("text-6xl font-bold", className)}
|
||||
{...props}
|
||||
style={
|
||||
{
|
||||
"--sparkles-first-color": `${colors.first}`,
|
||||
"--sparkles-second-color": `${colors.second}`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="relative inline-block">
|
||||
{sparkles.map((sparkle) => (
|
||||
<Sparkle key={sparkle.id} {...sparkle} />
|
||||
))}
|
||||
<strong>{children}</strong>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
78
resources/js/modules/auth/AuthDialog.jsx
Normal file
78
resources/js/modules/auth/AuthDialog.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useMitt } from '@/plugins/MittContext';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const AuthDialog = ({ onOpenChange }) => {
|
||||
const emitter = useMitt();
|
||||
|
||||
const [isLogin, setIsLogin] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
window.location.href = route('auth.google.redirect');
|
||||
};
|
||||
|
||||
// Listen for text element selection (but don't auto-open sidebar)
|
||||
useEffect(() => {
|
||||
const handleOpenAuth = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
emitter.on('401', handleOpenAuth);
|
||||
|
||||
return () => {
|
||||
emitter.off('401', handleOpenAuth);
|
||||
};
|
||||
}, [emitter]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-center">{isLogin ? 'Welcome back' : 'Create account'}</DialogTitle>
|
||||
<DialogDescription className="text-center">
|
||||
{isLogin ? 'Sign in to your account to continue' : 'Sign up to get started'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-6">
|
||||
<Button variant="outline" className="bg-background hover:bg-accent h-12 w-full" type="button" onClick={() => handleGoogleLogin()}>
|
||||
<svg className="mr-3 h-5 w-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
{isLogin ? 'Sign in with Google' : 'Sign up with Google'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground text-center text-xs">By continuing, you agree to our Terms of Service and Privacy Policy</div>
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
{isLogin ? "Don't have an account?" : 'Already have an account?'}{' '}
|
||||
<Button variant="link" className="px-0 text-sm font-semibold" onClick={() => setIsLogin(!isLogin)}>
|
||||
{isLogin ? 'Sign up' : 'Sign in'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthDialog;
|
||||
@@ -8,6 +8,7 @@ import useVideoEditorStore from '@/stores/VideoEditorStore';
|
||||
// Import fonts first - this loads all Fontsource packages
|
||||
import '@/modules/editor/fonts';
|
||||
|
||||
import UpgradeSheet from '../upgrade/upgrade-sheet';
|
||||
import EditNavSidebar from './partials/edit-nav-sidebar';
|
||||
import EditSidebar from './partials/edit-sidebar';
|
||||
import EditorCanvas from './partials/editor-canvas';
|
||||
@@ -216,6 +217,7 @@ const Editor = () => {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<UpgradeSheet />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { usePage } from '@inertiajs/react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import useLocalSettingsStore from '@/stores/localSettingsStore';
|
||||
import { SettingsIcon } from 'lucide-react';
|
||||
|
||||
export default function EditNavSidebar({ isOpen, onClose }) {
|
||||
const { auth } = usePage().props;
|
||||
|
||||
const { getSetting, setSetting } = useLocalSettingsStore();
|
||||
|
||||
return (
|
||||
@@ -17,36 +18,22 @@ export default function EditNavSidebar({ isOpen, onClose }) {
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="link">
|
||||
<SettingsIcon className="h-6 w-6" /> Settings
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
<DialogDescription>Change your settings here.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="grid px-2">
|
||||
{/* {!auth.user && <Button variant="outline">Join Now</Button>}
|
||||
{!auth.user && <Button variant="link">Login</Button>} */}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="genAlphaSlang"
|
||||
checked={getSetting('genAlphaSlang')}
|
||||
onCheckedChange={() => setSetting('genAlphaSlang', !getSetting('genAlphaSlang'))}
|
||||
/>
|
||||
<label
|
||||
htmlFor="genAlphaSlang"
|
||||
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Use gen alpha slang
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter></DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="grid px-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.location.href = route('home');
|
||||
}}
|
||||
variant="link"
|
||||
>
|
||||
Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
import CoinIcon from '@/reusables/coin-icon';
|
||||
import { useMitt } from '@/plugins/MittContext';
|
||||
import CartIcon from '@/reusables/cart-icon';
|
||||
import useLocalSettingsStore from '@/stores/localSettingsStore';
|
||||
import { Menu } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
const EditorHeader = ({ className = '', onNavClick = () => {}, isNavActive = false }) => {
|
||||
const { getSetting } = useLocalSettingsStore();
|
||||
|
||||
const [openCoinDialog, setOpenCoinDialog] = useState(false);
|
||||
const emitter = useMitt();
|
||||
|
||||
const openUpgradeSheet = () => {
|
||||
emitter.emit('openUpgradeSheet');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex w-full items-center justify-between rounded-xl bg-white p-2 shadow-sm dark:bg-neutral-700', className)}>
|
||||
@@ -19,33 +22,16 @@ const EditorHeader = ({ className = '', onNavClick = () => {}, isNavActive = fal
|
||||
|
||||
<h1 className="font-display ml-0 text-lg tracking-wide md:ml-3 md:text-xl">MEMEAIGEN</h1>
|
||||
|
||||
<Button variant="outline" className="inline-flex gap-1 rounded" onClick={() => setOpenCoinDialog(true)}>
|
||||
<span className="text-sm font-semibold">0</span>
|
||||
<CoinIcon className="h-8 w-8" />
|
||||
<Button
|
||||
variant="outline"
|
||||
className="inline-flex gap-1 rounded"
|
||||
onClick={() => {
|
||||
openUpgradeSheet();
|
||||
}}
|
||||
>
|
||||
{/* <span className="text-sm font-semibold">0</span> */}
|
||||
<CartIcon className="h-8 w-8" />
|
||||
</Button>
|
||||
|
||||
<Dialog open={openCoinDialog} onOpenChange={(open) => setOpenCoinDialog(open)}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{getSetting('genAlphaSlang') ? 'Bruh' : 'Feature coming soon'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{getSetting('genAlphaSlang')
|
||||
? "No cap, soon you'll be able to get AI cooking memes that absolutely slay! But lowkey fam, we gotta focus on making these core features bussin' first."
|
||||
: "Soon you'll be able to prompt AI to generate memes! Let us focus on nailing the core features first."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<div className="flex justify-between gap-1">
|
||||
{/* <span class="text-muted-foreground text-xs italic">
|
||||
Note: You can turn {getSetting('genAlphaSlang') ? 'off' : 'on'} gen alpha slang in Settings.
|
||||
</span> */}
|
||||
<Button variant="outline" onClick={() => setOpenCoinDialog(false)}>
|
||||
{getSetting('genAlphaSlang') ? 'Bet' : 'Okay'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
103
resources/js/modules/upgrade/partials/upgrade-plan-carousel.tsx
Normal file
103
resources/js/modules/upgrade/partials/upgrade-plan-carousel.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from '@/components/ui/carousel';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Autoplay from 'embla-carousel-autoplay';
|
||||
import { CheckCircle, Handshake, Lock, Zap } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const upgradePlanData = [
|
||||
{
|
||||
icon: Zap,
|
||||
title: 'Remove watermarks',
|
||||
description: 'Export up to 50 watermark-free videos, perfect for posting to your creator channel (8¢ per video)',
|
||||
},
|
||||
{
|
||||
icon: CheckCircle,
|
||||
title: 'Personal license included',
|
||||
description: 'Full rights to use videos for personal social media, creator and non-commercial projects',
|
||||
},
|
||||
{
|
||||
icon: Lock,
|
||||
title: 'Lock in your pricing',
|
||||
description: 'Subscribe now and keep this price forever - even when we raise prices for new users',
|
||||
},
|
||||
{
|
||||
icon: Handshake,
|
||||
title: 'Support our development',
|
||||
description: 'Help us to improve and grow so you get the best features & experience',
|
||||
},
|
||||
];
|
||||
|
||||
const UpgradePlanCarousel = () => {
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCount(api.scrollSnapList().length);
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
|
||||
api.on('select', () => {
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const scrollTo = (index: number) => {
|
||||
api?.scrollTo(index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl space-y-4">
|
||||
<Carousel
|
||||
plugins={[
|
||||
Autoplay({
|
||||
delay: 5500,
|
||||
}),
|
||||
]}
|
||||
setApi={setApi}
|
||||
className="w-full"
|
||||
>
|
||||
<CarouselContent>
|
||||
{upgradePlanData.map((item, index) => {
|
||||
const IconComponent = item.icon;
|
||||
return (
|
||||
<CarouselItem key={index}>
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<div className="space-y-2 text-center">
|
||||
<div className="mx-auto mb-0 flex h-20 w-20 items-center justify-center rounded-full">
|
||||
<IconComponent className="h-10 w-10" />
|
||||
</div>
|
||||
<h3 className="text-xl font-bold">{item.title}</h3>
|
||||
<p className="max-w-sm text-sm">{item.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
);
|
||||
})}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
|
||||
{/* Centered Dot Navigation */}
|
||||
<div className="flex justify-center space-x-2">
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => scrollTo(index)}
|
||||
className={cn(
|
||||
'h-3 w-3 rounded-full transition-all duration-200 hover:scale-110',
|
||||
current === index + 1 ? 'bg-primary scale-110' : 'bg-muted-foreground/30 hover:bg-muted-foreground/50',
|
||||
)}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpgradePlanCarousel;
|
||||
198
resources/js/modules/upgrade/upgrade-sheet.jsx
Normal file
198
resources/js/modules/upgrade/upgrade-sheet.jsx
Normal file
@@ -0,0 +1,198 @@
|
||||
// resources/js/Pages/User/Partials/upgrade-sheet.jsx
|
||||
import { SparklesText } from '@/components/magicui/sparkles-text';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||
import { Spinner } from '@/components/ui/spinner.js';
|
||||
import { useMitt } from '@/plugins/MittContext';
|
||||
import CartIcon from '@/reusables/cart-icon.jsx';
|
||||
import CoinIcon from '@/reusables/coin-icon.jsx';
|
||||
import usePricingStore from '@/stores/PricingStore.js';
|
||||
import { useEffect, useState } from 'react';
|
||||
import UpgradePlanCarousel from './partials/upgrade-plan-carousel.tsx';
|
||||
|
||||
const UpgradeSheet = () => {
|
||||
const { subscription, one_times, isFetchingPricing, fetchPricing, isCheckingOut, checkoutSubscribe } = usePricingStore();
|
||||
|
||||
// State to control sheet visibility
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Get mitt emitter
|
||||
const emitter = useMitt();
|
||||
|
||||
useEffect(() => {
|
||||
fetchPricing();
|
||||
}, []);
|
||||
|
||||
// FAQ data array
|
||||
const faqData = [
|
||||
{
|
||||
q: "What's included in the $4/m Personal Creator Plan?",
|
||||
a: 'This $4/m plan includes 50 non-watermark videos for export (8¢ per video), which is sufficient for every creator to post almost twice a day. <br><br>This plan also includes a Personal license, which allows you to use the app for personal social media, creator and non-commercial projects.<br><br> If you are a creator looking to monetize your channel, this plan is the perfect choice for you.',
|
||||
},
|
||||
{
|
||||
q: 'Why are your plans extremely affordable for creators?',
|
||||
a: "We're glad you think this way! As creators ourselves, we know the journey of creating and monetizing your channel is not easy.<br><br> We believe that creators deserve access to high quality videos, and we're committed to making it accessible to every creator.<br><br>If one day you've made it to the top, don't forget us! We'd love to hear your growth stories.",
|
||||
},
|
||||
{
|
||||
q: 'Can I use the Personal Creator Plan for my business & commercial projects?',
|
||||
a: "A hard NO.<br><br> The Personal Creator Plan is designed for personal social media, creator and non-commercial projects.<br><br> If you are a creator looking to monetize your channel, this plan is the right choice for you. The Personal Creator Plan is designed to empower creators, helping them to monetize their channels and grow their audience. <br><br>However, if you are a business, we recommend you to use the Business Creator Plan (coming soon), which contains a Business License that you can use for commercial projects.<br><br>Contact us if you have an urgent need for this plan, and we'll see what can be done to help you.",
|
||||
},
|
||||
{
|
||||
q: 'How do I lock in my subscription price?',
|
||||
a: "Simple! Just subscribe to the plan before the next price change. We can't guarantee that you'll be able to purchase the same plan at the current price that you've locked in. But we'll do our best to make sure you get the best value for your money.",
|
||||
},
|
||||
{
|
||||
q: 'Is the $4/month pricing permanent?',
|
||||
a: 'Yes, the $4/month launch pricing is a special offer for our first 1000 users. After 1000 users, the plan will be priced at a higher rate. Lock in your launch pricing by subscribing now!',
|
||||
},
|
||||
{
|
||||
q: 'What can I do with credits?',
|
||||
a: 'You can use credits to access AI features such as AI caption generation & AI background generation.',
|
||||
},
|
||||
// {
|
||||
// q: 'What is the difference of purchasing credits from a subscription vs a one-time credit pack?',
|
||||
// a: 'When you purchase credits from a subscription, (e.g. 500 Credits/m) you get monthly allowance of 500 credits. Unused credits will not be carry forwarded to the next month.<br><br>When you purchase credits from a credit pack, (e.g. 500 Credit Pack) the credits will not expire and you can use them anytime.',
|
||||
// },
|
||||
{
|
||||
q: 'Can I cancel my subscription anytime?',
|
||||
a: "Yes! You can cancel your subscription at any time. Your plan will remain active until the end of your current billing period, and you'll retain access to all premium features during that time. Just know that you might not be able to purchase the same plan at the current price that you've locked in.",
|
||||
},
|
||||
{
|
||||
q: 'Do you offer refunds?',
|
||||
a: 'Yes, we offer refunds for subscription plans. Credits are non-refundable once purchased as they do not expire.',
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for open modal event
|
||||
const openModalListener = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
// Register listener for opening the modal
|
||||
emitter.on('openUpgradeSheet', openModalListener);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
emitter.off('openUpgradeSheet', openModalListener);
|
||||
};
|
||||
}, [emitter]);
|
||||
|
||||
// Handle sheet state changes
|
||||
const handleOpenChange = (open) => {
|
||||
setIsOpen(open);
|
||||
|
||||
// If sheet is closing, emit the close event
|
||||
if (!open) {
|
||||
emitter.emit('closeUpgradeSheet');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubscribe = (subscription) => {
|
||||
checkoutSubscribe(subscription.stripe_monthly_price_id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<SheetContent side="bottom" className="max-h-screen overflow-y-scroll pb-1">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center justify-center gap-2 sm:text-center">
|
||||
<CartIcon className={'h-4 w-4'} /> Store
|
||||
</SheetTitle>
|
||||
<SheetDescription className="hidden"> </SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mx-auto w-full max-w-[600px] space-y-3 px-4">
|
||||
{subscription ? (
|
||||
<div id="plan-purchase" className="mx-auto space-y-6 rounded-lg border p-4 text-center sm:p-7">
|
||||
<SparklesText className="text-xl font-bold sm:text-2xl">
|
||||
Upgrade to {subscription?.name} Plan<br></br> at only {subscription?.symbol}
|
||||
{subscription?.amount}
|
||||
{subscription?.primary_interval === 'month' ? '/m' : '/y'}*
|
||||
</SparklesText>
|
||||
<UpgradePlanCarousel />
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
disabled={isCheckingOut}
|
||||
onClick={() => {
|
||||
handleSubscribe(subscription);
|
||||
}}
|
||||
size="lg"
|
||||
className="mx-auto w-[220px] text-sm sm:text-base"
|
||||
>
|
||||
{isCheckingOut ? (
|
||||
<Spinner className="text-muted h-4 w-4" />
|
||||
) : (
|
||||
<span>
|
||||
Subscribe at ({subscription?.symbol}
|
||||
{subscription?.amount}
|
||||
{subscription?.primary_interval === 'month' ? '/m' : '/y'})*
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<div className="text-muted-foreground text-xs">* Launch pricing limited to first 1000 users</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
isFetchingPricing && (
|
||||
<div className="mx-auto w-full">
|
||||
<Spinner className="h-6 w-6" />
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
<div id="credit-purchase" className="space-y-6 rounded-lg border p-4 text-center sm:p-7">
|
||||
<div className="text-xl font-extrabold sm:text-2xl">Buy Credit Packs</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Unlock AI meme captions and backgrounds with credits. Perfect for overcoming creator's block and discovering new concepts.
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-3 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="grid">
|
||||
<div className="text inline-flex items-center font-semibold">
|
||||
<CoinIcon className="inline h-4 w-4 flex-shrink-0" />
|
||||
<span className="ml-1">500 Credit Pack</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs font-semibold break-words">
|
||||
Approx. 250 AI captions & 250 AI backgrounds
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="text-muted-foreground text-sm">$4.00</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full flex-shrink-0 sm:w-auto">Buy</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="faq" className="space-y-4 rounded-lg border p-4 sm:p-7">
|
||||
<div className="text-center text-xl font-extrabold sm:text-2xl">Frequently Asked Questions</div>
|
||||
<Accordion type="single" collapsible className="w-full" defaultValue="item-1">
|
||||
{faqData.map((faq, index) => (
|
||||
<AccordionItem key={index} value={`item-${index + 1}`}>
|
||||
<AccordionTrigger className="text-left text-sm break-words sm:text-base">{faq.q}</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col gap-4 text-balance">
|
||||
<div className="overflow-wrap-anywhere text-sm break-words" dangerouslySetInnerHTML={{ __html: faq.a }} />
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<SheetClose asChild className="hidden">
|
||||
<Button variant="outline">Close</Button>
|
||||
</SheetClose>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpgradeSheet;
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
import { emitter } from './MittContext';
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
withCredentials: true,
|
||||
@@ -16,6 +17,13 @@ axiosInstance.interceptors.response.use(
|
||||
toast.error(error.response.data.message + ' Please try again later.');
|
||||
}
|
||||
}
|
||||
|
||||
if (error.response && error.response.status === 401) {
|
||||
//toast.error('You are not logged in. Please login to continue.');
|
||||
|
||||
emitter.emit('401');
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
48
resources/js/reusables/cart-icon.jsx
Normal file
48
resources/js/reusables/cart-icon.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
const CartIcon = ({ className }) => {
|
||||
return (
|
||||
<svg className={className} width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M56.4498 34.3344H120.029H128.613H137.196V8.58359C137.197 3.84375 133.353 0 128.613 0H47.8658C43.1252 0 39.2822 3.84375 39.2822 8.58359V34.3344H47.8658H56.4498Z"
|
||||
fill="#F28618"
|
||||
/>
|
||||
<path
|
||||
d="M162.948 28.3266C162.059 28.3266 161.202 28.1914 160.395 27.941C159.32 27.6067 158.336 27.0668 157.488 26.3668C155.58 24.7926 154.364 22.4098 154.364 19.743V34.0488H99.9998H14.1622C11.2872 34.0488 8.60287 35.4891 7.01186 37.8836C5.42162 40.2785 5.13529 43.311 6.25014 45.9613L39.9533 126.077C41.2927 129.261 44.4107 131.332 47.8654 131.332H99.9998H154.363V148.498H162.947C167.688 148.498 171.531 152.342 171.531 157.082V122.748V71.2445V28.3266H162.948Z"
|
||||
fill="#BDFDFF"
|
||||
/>
|
||||
<path
|
||||
d="M39.954 126.076C41.2935 129.261 44.4114 131.331 47.8661 131.331H154.364V148.498H162.948C167.688 148.498 171.531 152.341 171.531 157.082V122.747V71.2445V28.3266H162.948C158.207 28.3266 154.364 24.4832 154.364 19.743V34.0488H14.1626C11.2876 34.0488 8.60324 35.4891 7.01222 37.8836C5.42199 40.2789 5.13566 43.311 6.2505 45.9613L39.954 126.076Z"
|
||||
fill="#FFCC33"
|
||||
/>
|
||||
<path
|
||||
d="M162.948 28.3265H171.531H185.837C190.578 28.3265 194.421 24.4832 194.421 19.743C194.421 15.0031 190.578 11.1594 185.837 11.1594H162.947C158.207 11.1594 154.364 15.0031 154.364 19.743C154.364 24.4832 158.207 28.3265 162.948 28.3265Z"
|
||||
fill="#F28618"
|
||||
/>
|
||||
<path
|
||||
d="M162.948 148.497H154.364H134.335H66.2375H31.9023C27.1617 148.497 23.3187 152.341 23.3187 157.081C23.3187 161.821 27.1621 165.664 31.9023 165.664H41.9625H51.3718H66.2371H81.1023H90.5117H110.06H119.469H134.334H149.2H158.609H162.946C167.687 165.664 171.53 161.821 171.53 157.081C171.53 152.341 167.688 148.497 162.948 148.497Z"
|
||||
fill="#F28618"
|
||||
/>
|
||||
<path
|
||||
d="M125.752 174.249C125.752 178.982 129.602 182.832 134.335 182.832C139.068 182.832 142.919 178.982 142.919 174.249C142.919 169.516 139.068 165.665 134.335 165.665C129.602 165.665 125.752 169.516 125.752 174.249Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M57.6534 174.249C57.6534 178.982 61.5042 182.832 66.237 182.832C70.9702 182.832 74.8206 178.982 74.8206 174.249C74.8206 169.516 70.9698 165.665 66.237 165.665C61.5042 165.665 57.6534 169.516 57.6534 174.249Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M134.335 165.665C139.068 165.665 142.919 169.516 142.919 174.248C142.919 178.981 139.068 182.832 134.335 182.832C129.602 182.832 125.752 178.981 125.752 174.248C125.752 169.516 129.602 165.665 134.335 165.665H119.47H110.061C109.108 168.352 108.584 171.24 108.584 174.248C108.584 188.448 120.136 200 134.335 200C148.534 200 160.086 188.448 160.086 174.248C160.086 171.239 159.563 168.352 158.61 165.665H149.2H134.335Z"
|
||||
fill="#898890"
|
||||
/>
|
||||
<path
|
||||
d="M66.2374 165.665C70.9706 165.665 74.821 169.516 74.821 174.248C74.821 178.981 70.9702 182.832 66.2374 182.832C61.5046 182.832 57.6538 178.981 57.6538 174.248C57.6538 169.516 61.5034 165.665 66.2374 165.665H51.3721H41.9628C41.01 168.352 40.4862 171.24 40.4862 174.248C40.4862 188.448 52.0382 200 66.2374 200C80.4366 200 91.9886 188.448 91.9886 174.248C91.9886 171.239 91.4651 168.352 90.512 165.665H81.1026H66.2374Z"
|
||||
fill="#57565C"
|
||||
/>
|
||||
<path
|
||||
d="M162.948 28.3266C158.207 28.3266 154.364 24.4832 154.364 19.743V34.0488H100V131.332H154.364V148.498H162.947C167.688 148.498 171.531 152.342 171.531 157.082V122.748V71.2445V28.3266H162.948Z"
|
||||
fill="#FFE14D"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartIcon;
|
||||
@@ -1,10 +1,4 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CoinIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CoinIcon: React.FC<CoinIconProps> = ({ className }) => {
|
||||
const CoinIcon = ({ className }) => {
|
||||
return (
|
||||
<svg className={className} width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_23_2)">
|
||||
95
resources/js/stores/PricingStore.js
Normal file
95
resources/js/stores/PricingStore.js
Normal file
@@ -0,0 +1,95 @@
|
||||
import axiosInstance from '@/plugins/axios-plugin';
|
||||
import { mountStoreDevtool } from 'simple-zustand-devtools';
|
||||
import { toast } from 'sonner';
|
||||
import { route } from 'ziggy-js';
|
||||
import { create } from 'zustand';
|
||||
import { devtools } from 'zustand/middleware';
|
||||
|
||||
const usePricingStore = create(
|
||||
devtools((set, get) => ({
|
||||
// Pricing Plans
|
||||
subscription: null,
|
||||
one_times: [],
|
||||
isFetchingPricing: false,
|
||||
|
||||
isCheckingOut: false,
|
||||
|
||||
checkoutSubscribe: async (price_id) => {
|
||||
console.log('checkoutSubscribe', price_id);
|
||||
|
||||
set({ isCheckingOut: true });
|
||||
try {
|
||||
const response = await axiosInstance.post(route('api.user.subscribe'), { price_id: price_id });
|
||||
|
||||
if (response?.data?.success?.data) {
|
||||
if (response.data.success.data.redirect) {
|
||||
window.location.href = response.data.success.data.redirect;
|
||||
}
|
||||
} else {
|
||||
throw 'Invalid API response';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(route('api.user.subscribe'));
|
||||
console.error('Error fetching:', error);
|
||||
set({ isCheckingOut: false });
|
||||
if (error?.response?.data?.error?.message?.length > 0) {
|
||||
toast.error(error.response.data.error.message);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
set({ isCheckingOut: false });
|
||||
}
|
||||
},
|
||||
|
||||
// Fetch backgrounds
|
||||
fetchPricing: async () => {
|
||||
set({ isFetchingPricing: true });
|
||||
try {
|
||||
const response = await axiosInstance.post(route('api.pricing_page'));
|
||||
|
||||
if (response?.data?.success?.data) {
|
||||
set({
|
||||
subscription: response.data.success.data.subscription,
|
||||
one_times: response.data.success.data.one_times,
|
||||
});
|
||||
return response.data.success.data;
|
||||
} else {
|
||||
throw 'Invalid API response';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(route('api.pricing_page'));
|
||||
console.error('Error fetching:', error);
|
||||
set({ isFetchingPricing: false });
|
||||
if (error?.response?.data?.error?.message?.length > 0) {
|
||||
toast.error(error.response.data.error.message);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
set({ isFetchingPricing: false });
|
||||
}
|
||||
},
|
||||
|
||||
// Reset store to default state
|
||||
restoreMemeStateToDefault: () => {
|
||||
console.log('restoreMemeStateToDefault');
|
||||
set({
|
||||
memes: [],
|
||||
backgrounds: [],
|
||||
isFetchingMemes: false,
|
||||
isFetchingBackgrounds: false,
|
||||
selectedMeme: null,
|
||||
selectedBackground: null,
|
||||
});
|
||||
},
|
||||
})),
|
||||
{
|
||||
name: 'MemeStore',
|
||||
store: 'MemeStore',
|
||||
},
|
||||
);
|
||||
|
||||
if (import.meta.env.APP_ENV === 'local') {
|
||||
mountStoreDevtool('PricingStore', usePricingStore);
|
||||
}
|
||||
|
||||
export default usePricingStore;
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user