Update
This commit is contained in:
94
CLAUDE.md
Normal file
94
CLAUDE.md
Normal 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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Helpers\FirstParty\Stripe;
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
class StripeHelper
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
|
||||
28
app/Http/Controllers/UserAccountController.php
Normal file
28
app/Http/Controllers/UserAccountController.php
Normal 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'))
|
||||
]
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -26,4 +26,11 @@ class Plan extends Model
|
||||
'name',
|
||||
'tier',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
'id',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'laravel_through_key',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ createInertiaApp({
|
||||
<AxiosProvider>
|
||||
<Toaster position="top-right" />
|
||||
<AuthDialog />
|
||||
|
||||
<App {...props} />
|
||||
</AxiosProvider>
|
||||
</MittProvider>
|
||||
|
||||
18
resources/js/modules/auth/auth-user.jsx
Normal file
18
resources/js/modules/auth/auth-user.jsx
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
50
resources/js/stores/UserStore.js
Normal file
50
resources/js/stores/UserStore.js
Normal 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
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user