This commit is contained in:
ct
2025-07-02 18:25:12 +08:00
parent 68a47ec916
commit 0aa0d9569f
20 changed files with 515 additions and 193 deletions

94
CLAUDE.md Normal file
View File

@@ -0,0 +1,94 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Frontend Development
- `npm run dev` - Start Vite development server
- `npm run build` - Build for production
- `npm run build:ssr` - Build with SSR support
- `npm run lint` - Run ESLint with auto-fix
- `npm run types` - Run TypeScript type checking
- `npm run format` - Format code with Prettier
- `npm run format:check` - Check code formatting
### Backend Development
- `composer dev` - Start full development environment (Laravel server, queue worker, logs, Vite)
- `composer dev:ssr` - Start development with SSR
- `composer test` - Run PHP tests with Pest
- `php artisan serve` - Start Laravel development server
- `php artisan queue:listen --tries=1` - Start queue worker
- `php artisan pail --timeout=0` - Start log viewer
## Architecture Overview
### Stack
- **Backend**: Laravel 12 with PHP 8.2+
- **Frontend**: React 19 + TypeScript with Inertia.js
- **Database**: PostgreSQL with pgvector extension for embeddings
- **Queue System**: Laravel Horizon for job processing
- **Payment**: Laravel Cashier with Stripe integration
- **Media Processing**: FFmpeg integration via pbmedia/laravel-ffmpeg
- **Authentication**: Laravel Sanctum + Google OAuth via Socialite
### Core Application Structure
**MemeAI Generator Platform**: This is a meme generation platform that uses AI to create memes from user inputs.
#### Key Models & Relationships
- `User` - Uses Billable trait for Stripe, has UUID-based public IDs
- `Meme` - Generated memes with keyword categorization (action/emotion/misc)
- `MemeMedia` - Template media files for meme generation
- `BackgroundMedia` - Background images/videos for memes
- `Category` - Hierarchical categories using kalnoy/nestedset
- `UserUsage` & `UserPlan` - Usage tracking and subscription management
- `KeywordEmbedding` & `MemeMediaEmbedding` - Vector embeddings for content matching
#### AI Integration Points
- `CloudflareAI`, `OpenAI`, `RunwareAI` - Multiple AI service integrations
- `MemeGenerator` - Core meme generation logic with keyword matching via vector similarity
- Uses pgvector for semantic search and content matching
#### Frontend Architecture
- **Inertia.js** full-stack integration between Laravel and React
- **Zustand** for state management (VideoEditorStore, MediaStore, UserStore, PricingStore)
- **Radix UI** + **Tailwind CSS** for component library
- **Konva.js** + **React-Konva** for canvas-based video/meme editing
- **FFmpeg.wasm** for client-side media processing
#### Media Processing Pipeline
- Video templates stored as JSON configurations
- Canvas-based editor with timeline support
- Server-side FFmpeg processing for final video generation
- S3/R2 storage integration via Laravel Filesystem
#### Key Helper Classes
- `MediaEngine` - Media processing utilities
- `PurchaseHelper`, `SubscriptionHelper` - Payment processing
- `WatermarkUsageHelper` - Usage tracking and limits
- `StripeHelper` - Stripe integration utilities
### Development Notes
#### Testing
- Uses Pest PHP for backend testing
- Test files in `tests/Feature/` and `tests/Unit/`
- Run tests with `composer test`
#### Code Organization
- Custom helpers in `app/Helpers/FirstParty/`
- Global helper functions in `app/Helpers/Global/helpers.php`
- Frontend components organized by feature in `resources/js/modules/`
- Reusable UI components in `resources/js/components/ui/`
#### Special Configurations
- CORS headers configured for FFmpeg.wasm in Vite config
- Vector search capabilities via PostgreSQL pgvector extension
- Hashids for public ID generation (users, etc.)
- Queue-based background processing for media generation
#### Environment-Specific Features
- Test routes can be enabled via `ENABLE_TEST_ROUTES=true`
- Uses Laravel Horizon for queue management in production
- Supports both regular and SSR deployments

View File

@@ -12,135 +12,136 @@ post {
body:json {
{
"id": "evt_1Rg71IEEXQJo9EEO4GHqZdRZ",
"id": "evt_1Rg71IEEXQJo9EEOmxJtfwVm",
"object": "event",
"api_version": "2025-05-28.basil",
"created": 1751387215,
"data": {
"object": {
"id": "sub_1Rg71FEEXQJo9EEO18sXSEho",
"object": "subscription",
"id": "in_1Rg71FEEXQJo9EEOmxbGjtdH",
"object": "invoice",
"account_country": "MY",
"account_name": "MEMEAIGEN sandbox",
"account_tax_ids": null,
"amount_due": 400,
"amount_overpaid": 0,
"amount_paid": 400,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_percent": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null
},
"billing_cycle_anchor": 1751387213,
"billing_cycle_anchor_config": null,
"billing_mode": {
"type": "classic"
},
"billing_thresholds": null,
"cancel_at": null,
"cancel_at_period_end": false,
"canceled_at": null,
"cancellation_details": {
"comment": null,
"feedback": null,
"reason": null
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "subscription_create",
"collection_method": "charge_automatically",
"created": 1751387213,
"currency": "usd",
"custom_fields": null,
"customer": "cus_SbGYl34MpG4nv5",
"days_until_due": null,
"default_payment_method": "pm_1Rg71EEEXQJo9EEOJWUAU6EQ",
"customer_address": null,
"customer_email": "memeaigen.com@gmail.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": null,
"discounts": [],
"ended_at": null,
"invoice_settings": {
"account_tax_ids": null,
"issuer": {
"type": "self"
}
"due_date": null,
"effective_at": 1751387213,
"ending_balance": 0,
"footer": null,
"from_invoice": null,
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_1RfMzuEEXQJo9EEO/test_YWNjdF8xUmZNenVFRVhRSm85RUVPLF9TYkplU0VDWWdKSG53bDVZT2VuNjNGZFFaaFJsbFFkLDE0MTkyODAxNg0200EQHRg6hn?s=ap",
"invoice_pdf": "https://pay.stripe.com/invoice/acct_1RfMzuEEXQJo9EEO/test_YWNjdF8xUmZNenVFRVhRSm85RUVPLF9TYkplU0VDWWdKSG53bDVZT2VuNjNGZFFaaFJsbFFkLDE0MTkyODAxNg0200EQHRg6hn/pdf?s=ap",
"issuer": {
"type": "self"
},
"items": {
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"object": "list",
"data": [
{
"id": "si_SbJesuW5WgGoZ7",
"object": "subscription_item",
"billing_thresholds": null,
"created": 1751387213,
"current_period_end": 1754065613,
"current_period_start": 1751387213,
"id": "il_1Rg71FEEXQJo9EEOhVjRevMb",
"object": "line_item",
"amount": 400,
"currency": "usd",
"description": "1 × Personal Creator (at $4.00 / month)",
"discount_amounts": [],
"discountable": true,
"discounts": [],
"metadata": {},
"plan": {
"id": "price_1RfN2VEEXQJo9EEOzjPI2HGt",
"object": "plan",
"active": true,
"amount": 400,
"amount_decimal": "400",
"billing_scheme": "per_unit",
"created": 1751210467,
"currency": "usd",
"interval": "month",
"interval_count": 1,
"livemode": false,
"metadata": {},
"meter": null,
"nickname": null,
"product": "prod_SaY8TGjiPi5hWu",
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
"invoice": "in_1Rg71FEEXQJo9EEOmxbGjtdH",
"livemode": false,
"metadata": {
"is_on_session_checkout": "true"
},
"price": {
"id": "price_1RfN2VEEXQJo9EEOzjPI2HGt",
"object": "price",
"active": true,
"billing_scheme": "per_unit",
"created": 1751210467,
"currency": "usd",
"custom_unit_amount": null,
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"product": "prod_SaY8TGjiPi5hWu",
"recurring": {
"interval": "month",
"interval_count": 1,
"meter": null,
"trial_period_days": null,
"usage_type": "licensed"
"parent": {
"invoice_item_details": null,
"subscription_item_details": {
"invoice_item": null,
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": "sub_1Rg71FEEXQJo9EEO18sXSEho",
"subscription_item": "si_SbJesuW5WgGoZ7"
},
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "recurring",
"unit_amount": 400,
"type": "subscription_item_details"
},
"period": {
"end": 1754065613,
"start": 1751387213
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_1RfN2VEEXQJo9EEOzjPI2HGt",
"product": "prod_SaY8TGjiPi5hWu"
},
"type": "price_details",
"unit_amount_decimal": "400"
},
"quantity": 1,
"subscription": "sub_1Rg71FEEXQJo9EEO18sXSEho",
"tax_rates": []
"taxes": []
}
],
"has_more": false,
"total_count": 1,
"url": "/v1/subscription_items?subscription=sub_1Rg71FEEXQJo9EEO18sXSEho"
"url": "/v1/invoices/in_1Rg71FEEXQJo9EEOmxbGjtdH/lines"
},
"latest_invoice": "in_1Rg71FEEXQJo9EEOmxbGjtdH",
"livemode": false,
"metadata": {
"is_on_session_checkout": "true"
},
"next_pending_invoice_item_invoice": null,
"metadata": {},
"next_payment_attempt": null,
"number": "IN7OJLTH-0001",
"on_behalf_of": null,
"pause_collection": null,
"parent": {
"quote_details": null,
"subscription_details": {
"metadata": {
"is_on_session_checkout": "true"
},
"subscription": "sub_1Rg71FEEXQJo9EEO18sXSEho"
},
"type": "subscription_details"
},
"payment_settings": {
"default_mandate": null,
"payment_method_options": {
"acss_debit": null,
"bancontact": null,
"card": {
"network": null,
"request_three_d_secure": "automatic"
},
"customer_balance": null,
@@ -148,50 +149,34 @@ body:json {
"sepa_debit": null,
"us_bank_account": null
},
"payment_method_types": null,
"save_default_payment_method": "off"
"payment_method_types": null
},
"pending_invoice_item_interval": null,
"pending_setup_intent": null,
"pending_update": null,
"plan": {
"id": "price_1RfN2VEEXQJo9EEOzjPI2HGt",
"object": "plan",
"active": true,
"amount": 400,
"amount_decimal": "400",
"billing_scheme": "per_unit",
"created": 1751210467,
"currency": "usd",
"interval": "month",
"interval_count": 1,
"livemode": false,
"metadata": {},
"meter": null,
"nickname": null,
"product": "prod_SaY8TGjiPi5hWu",
"tiers_mode": null,
"transform_usage": null,
"trial_period_days": null,
"usage_type": "licensed"
"period_end": 1751387213,
"period_start": 1751387213,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"receipt_number": null,
"rendering": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
"statement_descriptor": null,
"status": "paid",
"status_transitions": {
"finalized_at": 1751387213,
"marked_uncollectible_at": null,
"paid_at": 1751387215,
"voided_at": null
},
"quantity": 1,
"schedule": null,
"start_date": 1751387213,
"status": "active",
"subtotal": 400,
"subtotal_excluding_tax": 400,
"test_clock": null,
"transfer_data": null,
"trial_end": null,
"trial_settings": {
"end_behavior": {
"missing_payment_method": "create_invoice"
}
},
"trial_start": null
},
"previous_attributes": {
"default_payment_method": null,
"status": "incomplete"
"total": 400,
"total_discount_amounts": [],
"total_excluding_tax": 400,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": null
}
},
"livemode": false,
@@ -200,6 +185,6 @@ body:json {
"id": null,
"idempotency_key": "e50baede-20b6-4a06-a2c4-15f43ca47dd4"
},
"type": "customer.subscription.updated"
"type": "invoice.paid"
}
}

View File

@@ -43,7 +43,6 @@ public static function getSubscriptionPlanByStripePriceID($stripe_price_id)
foreach ($plans as $plan) {
if ($plan['id'] == 'free') {
continue;
}
@@ -76,10 +75,10 @@ public static function getPlanSystemProperty($plan, $property)
// Inject environment into the path
// stripe.product_id.month becomes system.stripe.product_id.{env}.month
array_splice($propertyParts, 2, 0, $environment);
$fullPath = 'system.' . implode('.', $propertyParts);
$fullPath = 'system.'.implode('.', $propertyParts);
} else {
// For non-stripe properties, just prepend 'system.'
$fullPath = 'system.' . $property;
$fullPath = 'system.'.$property;
}
return data_get($plan, $fullPath);

View File

@@ -5,7 +5,6 @@
use App\Helpers\FirstParty\Stripe\StripeHelper;
use App\Models\Plan;
use App\Models\UserPlan;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Events\WebhookReceived;
class SubscriptionHelper
@@ -28,7 +27,7 @@ private static function handleSubscriptionUpsert(WebhookReceived $event)
{
$object = $event->payload['data']['object'];
//dump($object);
// dump($object);
$ignore_statuses = ['incomplete_expired', 'incomplete'];
@@ -41,7 +40,7 @@ private static function handleSubscriptionUpsert(WebhookReceived $event)
if ($user) {
foreach ($object['items']['data'] as $line_item) {
//dump($line_item);
// dump($line_item);
$stripe_price_id = $line_item['plan']['id'];
$current_period_end = $line_item['current_period_end'];

View File

@@ -4,7 +4,6 @@
use App\Helpers\FirstParty\Stripe\StripeHelper;
use App\Models\UserUsage;
use Illuminate\Support\Facades\App;
use Laravel\Cashier\Events\WebhookReceived;
class WatermarkUsageHelper
@@ -22,11 +21,11 @@ private static function handleInvoicePaid(WebhookReceived $event)
{
$object = $event->payload['data']['object'];
//dump($object);
// dump($object);
$accept_statuses = ['paid', 'partially_paid'];
if (!in_array($object['status'], $accept_statuses)) {
if (! in_array($object['status'], $accept_statuses)) {
return;
}
@@ -36,7 +35,7 @@ private static function handleInvoicePaid(WebhookReceived $event)
foreach ($object['lines']['data'] as $line_item) {
$stripe_price_id = $line_item['pricing']['price_details']['price'];
//dd($stripe_price_id);
// dd($stripe_price_id);
$subscription_config = PurchaseHelper::getSubscriptionPlanByStripePriceID($stripe_price_id);
@@ -57,7 +56,7 @@ private static function handleInvoicePaid(WebhookReceived $event)
}
}
//dd($subscription_config);
// dd($subscription_config);
}
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Helpers\FirstParty\Stripe;
use App\Models\User;
use Laravel\Cashier\Events\WebhookReceived;
class StripeHelper
{

View File

@@ -65,7 +65,7 @@ public function handleGoogleCallback()
return redirect()->intended(route('home'))->with('success', "You're now logged in!");
} catch (\Exception $e) {
//throw $e;
// throw $e;
$error_message = 'Google login failed. Please try again.';
if (config('app.debug')) {
$error_message = $e->getMessage();

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Auth;
class UserAccountController extends Controller
{
public function index()
{
$user = Auth::user();
$user->load('user_usage');
$user->load('plan');
return response()->json([
'success' => [
'data' => [
'user' => $user,
'billing' => [
'provider' => 'stripe',
'portal' => Auth::user()->billingPortalUrl(route('home'))
]
],
],
]);
}
}

View File

@@ -26,4 +26,11 @@ class Plan extends Model
'name',
'tier',
];
protected $hidden = [
'id',
'created_at',
'updated_at',
'laravel_through_key',
];
}

View File

@@ -3,6 +3,8 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
@@ -14,7 +16,7 @@
class User extends Authenticatable
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, Notifiable, SoftDeletes, Billable;
use Billable, HasApiTokens, HasFactory, Notifiable, SoftDeletes;
/**
* The attributes that are mass assignable.
@@ -37,8 +39,28 @@ class User extends Authenticatable
'password',
'remember_token',
'id',
'created_at',
'updated_at',
'deleted_at',
'uuid',
'stripe_id',
'google_id',
'email_verified_at',
'pm_last_four',
'pm_type',
'trial_ends_at',
'email',
];
protected $appends = ['ids'];
protected function ids(): Attribute
{
return Attribute::make(
get: fn($value, $attributes) => hashids_encode($attributes['id']),
);
}
/**
* Get the attributes that should be cast.
*
@@ -61,4 +83,19 @@ protected static function booted(): void
$model->uuid = $model->uuid ?? (string) Str::uuid();
});
}
public function user_usage()
{
return $this->hasOne(UserUsage::class, 'user_id');
}
public function user_plan()
{
return $this->hasOne(UserPlan::class, 'user_id');
}
public function plan()
{
return $this->hasOneThrough(Plan::class, UserPlan::class, 'user_id', 'id', 'id', 'plan_id');
}
}

View File

@@ -40,4 +40,14 @@ class UserPlan extends Model
'cancel_at',
'canceled_at',
];
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
public function plan()
{
return $this->belongsTo(Plan::class, 'plan_id');
}
}

View File

@@ -7,30 +7,45 @@
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
/**
* Class UserUsage
*
*
* @property int $id
* @property int $user_id
* @property int $non_watermark_videos_left
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*
* @package App\Models
*/
class UserUsage extends Model
{
protected $table = 'user_usages';
protected $table = 'user_usages';
protected $casts = [
'user_id' => 'int',
'non_watermark_videos_left' => 'int'
];
protected $casts = [
'user_id' => 'int',
'non_watermark_videos_left' => 'int',
];
protected $fillable = [
'user_id',
'non_watermark_videos_left'
];
protected $fillable = [
'user_id',
'non_watermark_videos_left',
];
protected $hidden = [
'created_at',
'updated_at',
'id',
'user_id',
];
protected $appends = ['ids'];
protected function ids(): Attribute
{
return Attribute::make(
get: fn($value, $attributes) => hashids_encode($attributes['id']),
);
}
}

View File

@@ -38,6 +38,7 @@ createInertiaApp({
<AxiosProvider>
<Toaster position="top-right" />
<AuthDialog />
<App {...props} />
</AxiosProvider>
</MittProvider>

View File

@@ -0,0 +1,18 @@
import useUserStore from '@/stores/UserStore';
import { usePage } from '@inertiajs/react';
import { useEffect } from 'react';
const AuthUser = () => {
const { auth } = usePage().props;
const { user, fetchUser } = useUserStore();
useEffect(() => {
if (auth.user) {
if (user == null) {
fetchUser();
}
}
}, [auth.user]);
};
export default AuthUser;

View File

@@ -1,4 +1,6 @@
// resources/js/Pages/User/Partials/upgrade-sheet.jsx
import { usePage } from '@inertiajs/react';
import { SparklesText } from '@/components/magicui/sparkles-text';
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion';
import { Button } from '@/components/ui/button';
@@ -8,11 +10,17 @@ 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 useUserStore from '@/stores/UserStore.js';
import { Download, ShieldIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import UpgradePlanCarousel from './partials/upgrade-plan-carousel.tsx';
const UpgradeSheet = () => {
const { subscription, one_times, isFetchingPricing, fetchPricing, isCheckingOut, checkoutSubscribe } = usePricingStore();
const { plan, billing, user_usage } = useUserStore();
const { auth } = usePage().props;
const [isRedirectingToBilling, setIsRedirectingToBilling] = useState(false);
// State to control sheet visibility
const [isOpen, setIsOpen] = useState(false);
@@ -104,36 +112,108 @@ const UpgradeSheet = () => {
</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>
{auth?.user ? (
<div id="stats">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{/* Non-watermark Exports */}
{user_usage?.non_watermark_videos_left && (
<div className="bg-card relative overflow-hidden rounded-xl border p-6 shadow-sm">
<div className="relative z-10">
<div className="mb-2 flex items-center gap-2">
<Download className="text-primary h-5 w-5" />
<span className="text-muted-foreground text-xs font-medium">Non-watermark Exports</span>
</div>
<div className="text-foreground text-xl font-bold">{user_usage?.non_watermark_videos_left}</div>
<div className="text-muted-foreground text-sm">exports left</div>
</div>
<Download className="bg-muted absolute -top-2 -right-2 h-20 w-20 opacity-15" />
</div>
)}
{/* Credits */}
<div className="bg-card relative overflow-hidden rounded-xl border p-6 shadow-sm">
<div className="relative z-10">
<div className="mb-2 flex items-center gap-2">
<CoinIcon className="text-primary h-5 w-5" />
<span className="text-muted-foreground text-xs font-medium">Credits</span>
</div>
<div className="text-foreground text-xl font-bold">999</div>
<div className="text-muted-foreground text-sm">available</div>
</div>
<CoinIcon className="bg-muted absolute -top-2 -right-2 h-20 w-20 opacity-15" />
</div>
</div>
</div>
) : (
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="text-xs">Have an account with us?</div>
<Button onClick={() => emitter.emit('login')} size="sm" variant="outline">
Login Now
</Button>
</div>
)}
{subscription ? (
<>
{auth.user && plan.tier != 'free' ? (
<div 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">You're now in the {plan?.name} plan!</SparklesText>
{billing?.portal && (
<Button
variant="outline"
onClick={() => {
setIsRedirectingToBilling(true);
window.location.replace(billing?.portal);
}}
disabled={isRedirectingToBilling}
>
{isRedirectingToBilling ? <Spinner className="text-muted-foreground h-4 w-4" /> : 'Manage Subscription'}
</Button>
)}
<div className="text-muted-foreground block rounded border p-3 text-center text-xs">
<ShieldIcon className="mr-1 mb-1 inline h-4 w-4" />
All our payments are securely processed by Stripe.
</div>
</div>
) : (
<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 className="text-muted-foreground block rounded border p-3 text-center text-xs">
<ShieldIcon className="mr-1 mb-1 inline h-4 w-4" />
All our payments are securely processed by Stripe.
</div>
</div>
</div>
)}
</>
) : (
isFetchingPricing && (
<div className="mx-auto w-full">

View File

@@ -1,3 +1,4 @@
import AuthUser from '@/modules/auth/auth-user';
import Editor from '@/modules/editor/editor.jsx';
import FlashMessages from '@/modules/flash/flash-messages';
@@ -6,6 +7,7 @@ const Home = () => {
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-800">
<Editor />
<FlashMessages />
<AuthUser />
</div>
);
};

View File

@@ -0,0 +1,50 @@
import axiosInstance from '@/plugins/axios-plugin';
import { mountStoreDevtool } from 'simple-zustand-devtools';
import { route } from 'ziggy-js';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
const useUserStore = create(
devtools((set, get) => ({
// Pricing Plans
user: null,
user_usage: null,
plan: {
name: 'Free',
tier: 'free',
},
billing: null,
isLoadingUser: false,
// Fetch backgrounds
fetchUser: async () => {
set({ isLoadingUser: true });
try {
const response = await axiosInstance.post(route('api.user'));
set({
user: response.data.success.data.user,
user_usage: response.data.success.data.user.user_usage,
plan: response.data.success.data.user.plan,
billing: response.data.success.data.billing,
});
//return response.data.success.data;
} catch (error) {
console.error(route('api.user'));
console.error('Error fetching:', error);
} finally {
set({ isLoadingUser: false });
}
},
})),
{
name: 'UserStore',
store: 'UserStore',
},
);
if (import.meta.env.APP_ENV === 'local') {
mountStoreDevtool('UserStore', useUserStore);
}
export default useUserStore;

File diff suppressed because one or more lines are too long

View File

@@ -2,8 +2,8 @@
use App\Http\Controllers\Auth\SanctumAuthController;
use App\Http\Controllers\FrontMediaController;
use App\Http\Controllers\UserAccountController;
use App\Http\Controllers\UserPurchaseController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::group(['prefix' => 'user'], function () {
@@ -16,9 +16,8 @@
Route::middleware('auth:sanctum')->group(function () {
Route::group(['prefix' => 'user'], function () {
Route::get('/', function (Request $request) {
return $request->user();
});
Route::post('/', [UserAccountController::class, 'index'])->name('api.user');
Route::post('/subscribe', [UserPurchaseController::class, 'subscribe'])->name('api.user.subscribe');

View File

@@ -57,8 +57,8 @@
});
});
require __DIR__.'/settings.php';
require __DIR__.'/auth.php';
require __DIR__ . '/settings.php';
require __DIR__ . '/auth.php';
}
Route::get('/', [FrontHomeController::class, 'index'])->name('home')->middleware('cacheResponse:3600');
Route::get('/', [FrontHomeController::class, 'index'])->name('home');