This commit is contained in:
ct
2025-07-01 23:13:09 +08:00
parent 79e7d7a49e
commit 209c022f1d
26 changed files with 374 additions and 50 deletions

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Helpers\FirstParty\Stripe;
use Laravel\Cashier\Events\WebhookReceived;
class StripeHelper
{
public static function handleSubscriptionWebhookEvents(WebhookReceived $event)
{
switch ($event->payload['type']) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
self::handleSubscriptionUpsert($event);
break;
case 'customer.subscription.deleted':
self::handleSubscriptionDelete($event);
break;
}
}
private static function handleSubscriptionUpsert(WebhookReceived $event)
{
///
}
private static function handleSubscriptionDelete(WebhookReceived $event)
{
///
}
}

View File

@@ -33,7 +33,7 @@ public function store(LoginRequest $request): RedirectResponse
$request->session()->regenerate(); $request->session()->regenerate();
return redirect()->intended(route('dashboard', absolute: false)); return redirect()->intended(route(config('platform.general.authed_route_redirect'), absolute: false));
} }
/** /**
@@ -46,6 +46,6 @@ public function destroy(Request $request): RedirectResponse
$request->session()->invalidate(); $request->session()->invalidate();
$request->session()->regenerateToken(); $request->session()->regenerateToken();
return redirect('/'); return redirect(route('home'));
} }
} }

View File

@@ -36,6 +36,6 @@ public function store(Request $request): RedirectResponse
$request->session()->put('auth.password_confirmed_at', time()); $request->session()->put('auth.password_confirmed_at', time());
return redirect()->intended(route('dashboard', absolute: false)); return redirect()->intended(route(config('platform.general.authed_route_redirect'), absolute: false));
} }
} }

View File

@@ -14,7 +14,7 @@ class EmailVerificationNotificationController extends Controller
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
if ($request->user()->hasVerifiedEmail()) { if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false)); return redirect()->intended(route(config('platform.general.authed_route_redirect'), absolute: false));
} }
$request->user()->sendEmailVerificationNotification(); $request->user()->sendEmailVerificationNotification();

View File

@@ -16,7 +16,7 @@ class EmailVerificationPromptController extends Controller
public function __invoke(Request $request): Response|RedirectResponse public function __invoke(Request $request): Response|RedirectResponse
{ {
return $request->user()->hasVerifiedEmail() return $request->user()->hasVerifiedEmail()
? redirect()->intended(route('dashboard', absolute: false)) ? redirect()->intended(route(config('platform.general.authed_route_redirect'), absolute: false))
: Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]); : Inertia::render('auth/verify-email', ['status' => $request->session()->get('status')]);
} }
} }

View File

@@ -31,7 +31,7 @@ public function create(): Response
public function store(Request $request): RedirectResponse public function store(Request $request): RedirectResponse
{ {
$request->validate([ $request->validate([
'email' => 'required|string|lowercase|email|max:255|unique:'.User::class, 'email' => 'required|string|lowercase|email|max:255|unique:' . User::class,
'password' => ['required', 'confirmed', Rules\Password::defaults()], 'password' => ['required', 'confirmed', Rules\Password::defaults()],
]); ]);
@@ -44,6 +44,6 @@ public function store(Request $request): RedirectResponse
Auth::login($user); Auth::login($user);
return to_route('dashboard'); return to_route(config('platform.general.authed_route_redirect'));
} }
} }

View File

@@ -15,7 +15,7 @@ class VerifyEmailController extends Controller
public function __invoke(EmailVerificationRequest $request): RedirectResponse public function __invoke(EmailVerificationRequest $request): RedirectResponse
{ {
if ($request->user()->hasVerifiedEmail()) { if ($request->user()->hasVerifiedEmail()) {
return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); return redirect()->intended(route(config('platform.general.authed_route_redirect'), absolute: false) . '?verified=1');
} }
if ($request->user()->markEmailAsVerified()) { if ($request->user()->markEmailAsVerified()) {
@@ -25,6 +25,6 @@ public function __invoke(EmailVerificationRequest $request): RedirectResponse
event(new Verified($user)); event(new Verified($user));
} }
return redirect()->intended(route('dashboard', absolute: false).'?verified=1'); return redirect()->intended(route(config('platform.general.authed_route_redirect'), absolute: false) . '?verified=1');
} }
} }

View File

@@ -4,6 +4,7 @@
use App; use App;
use App\Models\User; use App\Models\User;
use App\Models\UserPlan;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite; use Laravel\Socialite\Facades\Socialite;
@@ -60,7 +61,7 @@ public function handleGoogleCallback()
} }
} }
return redirect()->intended(route('home')); return redirect()->intended(route('home'))->with('success', "You're now logged in!");
} catch (\Exception $e) { } catch (\Exception $e) {
throw $e; throw $e;
@@ -73,7 +74,17 @@ public function handleGoogleCallback()
} }
} }
private function setupUser($user) {} private function setupUser($user)
{
$user_plan = UserPlan::where('user_id', $user->id)->first();
if (!$user_plan) {
$user_plan = UserPlan::create([
'user_id' => $user->id,
'plan_id' => 'free',
]);
}
}
private function getMockGoogleUser() private function getMockGoogleUser()
{ {

View File

@@ -32,10 +32,11 @@ public function subscribe(Request $request)
$payload = [ $payload = [
'mode' => 'subscription', 'mode' => 'subscription',
'success_url' => route('subscribe.success').'?'.'session_id={CHECKOUT_SESSION_ID}', 'success_url' => route('subscribe.success') . '?' . 'session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('subscribe.cancelled').'?'.'session_id={CHECKOUT_SESSION_ID}', 'cancel_url' => route('subscribe.cancelled') . '?' . 'session_id={CHECKOUT_SESSION_ID}',
'line_items' => [[ 'line_items' => [[
'price' => $price_id, 'price' => $price_id,
'quantity' => 1,
]], ]],
]; ];
@@ -60,10 +61,7 @@ public function subscribeSuccess(Request $request)
Session::forget('checkout_session_id'); Session::forget('checkout_session_id');
return redirect()->route('home')->with('success', [ return redirect()->route('home')->with('success', 'Thank you for subscribing! Your subscription should be active momentarily. Please refresh the page if you do not see your plan.');
'message' => 'Thank you for subscribing! Your subscription should be active momentarily. Please refresh the page if you do not see your plan.',
'action' => 'subscription_success',
]);
} }
public function subscribeCancelled(Request $request) public function subscribeCancelled(Request $request)
@@ -73,10 +71,7 @@ public function subscribeCancelled(Request $request)
Session::forget('checkout_session_id'); Session::forget('checkout_session_id');
} }
return redirect()->route('home')->with('error', [ return redirect()->route('home')->with('error', "You've decided not to complete the payment at this time. No charges have been made to your account.");
'message' => "You've decided not to complete the payment at this time. No charges have been made to your account.",
'action' => 'subscription_cancelled',
]);
} }
// PURCHASE (ONE TIME) // PURCHASE (ONE TIME)
@@ -86,8 +81,8 @@ public function purchase(Request $request)
$price_id = $request->input('price_id'); $price_id = $request->input('price_id');
$payload = [ $payload = [
'success_url' => route('subscribe.success').'?'.'session_id={CHECKOUT_SESSION_ID}', 'success_url' => route('subscribe.success') . '?' . 'session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('subscribe.cancelled').'?'.'session_id={CHECKOUT_SESSION_ID}', 'cancel_url' => route('subscribe.cancelled') . '?' . 'session_id={CHECKOUT_SESSION_ID}',
]; ];
$checkout_session = Auth::user()->checkout([$price_id => 1], $payload); $checkout_session = Auth::user()->checkout([$price_id => 1], $payload);
@@ -112,10 +107,7 @@ public function purchaseSuccess(Request $request)
Session::forget('checkout_session_id'); Session::forget('checkout_session_id');
return redirect()->route('home')->with('success', [ return redirect()->route('home')->with('success', "Thank you for purchasing! Your purchase should be active momentarily. Please refresh the page if you do not see your plan.");
'message' => 'Thank you for purchasing! Your purchase should be active momentarily. Please refresh the page if you do not see your plan.',
'action' => 'purchase_success',
]);
} }
public function purchaseCancelled(Request $request) public function purchaseCancelled(Request $request)
@@ -124,9 +116,6 @@ public function purchaseCancelled(Request $request)
Session::forget('checkout_session_id'); Session::forget('checkout_session_id');
} }
return redirect()->route('home')->with('error', [ return redirect()->route('home')->with('error', "You've decided not to complete the payment at this time. No charges have been made to your account.");
'message' => "You've decided not to complete the payment at this time. No charges have been made to your account.",
'action' => 'purchase_cancelled',
]);
} }
} }

View File

@@ -47,11 +47,16 @@ public function share(Request $request): array
'user' => $request->user(), 'user' => $request->user(),
'user_is_admin' => user_is_master_admin($request->user()), 'user_is_admin' => user_is_master_admin($request->user()),
], ],
'ziggy' => fn (): array => [ 'ziggy' => fn(): array => [
...(new Ziggy)->toArray(), ...(new Ziggy)->toArray(),
'location' => $request->url(), 'location' => $request->url(),
], ],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
'flash' => [
'message' => fn() => $request->session()->get('message'),
'error' => fn() => $request->session()->get('error'),
'success' => fn() => $request->session()->get('success'),
],
]; ];
} }
} }

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Listeners;
use App\Helpers\FirstParty\Stripe\StripeHelper;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Laravel\Cashier\Events\WebhookReceived;
class StripeEventListener
{
/**
* Create the event listener.
*/
public function __construct()
{
//
}
/**
* Handle the event.
*/
public function handle(WebhookReceived $event): void
{
StripeHelper::handleSubscriptionWebhookEvents($event);
}
}

31
app/Models/Plan.php Normal file
View File

@@ -0,0 +1,31 @@
<?php
/**
* Created by Reliese Model.
*/
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
/**
* Class Plan
*
* @property int $id
* @property string $name
* @property string $tier
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*
* @package App\Models
*/
class Plan extends Model
{
protected $table = 'plans';
protected $fillable = [
'name',
'tier'
];
}

View File

@@ -24,6 +24,7 @@ class User extends Authenticatable
'email', 'email',
'password', 'password',
'uuid', 'uuid',
'google_id',
]; ];
/** /**

45
app/Models/UserPlan.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
/**
* Created by Reliese Model.
*/
namespace App\Models;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
/**
* Class UserPlan
*
* @property int $id
* @property int $user_id
* @property int $plan_id
* @property Carbon|null $current_period_end
* @property Carbon|null $cancel_at
* @property Carbon|null $canceled_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*
* @package App\Models
*/
class UserPlan extends Model
{
protected $table = 'user_plans';
protected $casts = [
'user_id' => 'int',
'plan_id' => 'int',
'current_period_end' => 'datetime',
'cancel_at' => 'datetime',
'canceled_at' => 'datetime'
];
protected $fillable = [
'user_id',
'plan_id',
'current_period_end',
'cancel_at',
'canceled_at'
];
}

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('plans', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('tier');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('plans');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_plans', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id');
$table->foreignId('plan_id');
$table->dateTime('current_period_end')->nullable();
$table->dateTime('cancel_at')->nullable();
$table->dateTime('canceled_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_plans');
}
};

View File

@@ -0,0 +1,43 @@
<?php
namespace Database\Seeders;
use DB;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class PlanSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$plans = config('platform.purchases.subscriptions');
foreach ($plans as $plan) {
if ($plan['type'] !== 'subscription_plans') {
continue;
}
// Check if plan with this tier already exists
$existingPlan = DB::table('plans')
->where('tier', $plan['id'])
->first();
if (! $existingPlan) {
DB::table('plans')->insert([
'name' => $plan['name'],
'tier' => $plan['id'],
'created_at' => now(),
'updated_at' => now(),
]);
$this->command->info("Inserted plan: {$plan['name']}");
} else {
$this->command->info("Skipped existing plan tier: {$plan['id']}");
}
}
}
}

View File

@@ -10,7 +10,7 @@ import AppLogo from './app-logo';
const mainNavItems: NavItem[] = [ const mainNavItems: NavItem[] = [
{ {
title: 'Dashboard', title: 'Dashboard',
href: route('dashboard'), href: route(config('platform.general.authed_route_redirect')),
icon: LayoutGrid, icon: LayoutGrid,
}, },
]; ];

View File

@@ -5,7 +5,7 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
import { useMitt } from '@/plugins/MittContext'; import { useMitt } from '@/plugins/MittContext';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
const AuthDialog = ({ onOpenChange }) => { const AuthDialog = ({}) => {
const emitter = useMitt(); const emitter = useMitt();
const [isLogin, setIsLogin] = useState(false); const [isLogin, setIsLogin] = useState(false);
@@ -21,13 +21,31 @@ const AuthDialog = ({ onOpenChange }) => {
setIsOpen(true); setIsOpen(true);
}; };
const handleOpenLogin = () => {
setIsLogin(true);
setIsOpen(true);
};
const handleOpenJoin = () => {
setIsLogin(false);
setIsOpen(true);
};
emitter.on('401', handleOpenAuth); emitter.on('401', handleOpenAuth);
emitter.on('login', handleOpenLogin);
emitter.on('join', handleOpenJoin);
return () => { return () => {
emitter.off('401', handleOpenAuth); emitter.off('401', handleOpenAuth);
emitter.off('login', handleOpenLogin);
emitter.off('join', handleOpenJoin);
}; };
}, [emitter]); }, [emitter]);
const onOpenChange = (open) => {
setIsOpen(open);
};
return ( return (
<Dialog open={isOpen} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[400px]"> <DialogContent className="sm:max-w-[400px]">

View File

@@ -2,16 +2,28 @@ import { usePage } from '@inertiajs/react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { useMitt } from '@/plugins/MittContext';
import useLocalSettingsStore from '@/stores/localSettingsStore'; import useLocalSettingsStore from '@/stores/localSettingsStore';
import { Link } from '@inertiajs/react';
export default function EditNavSidebar({ isOpen, onClose }) { export default function EditNavSidebar({ isOpen, onClose }) {
const { auth } = usePage().props; const { auth } = usePage().props;
const emitter = useMitt();
const { getSetting, setSetting } = useLocalSettingsStore(); const { getSetting, setSetting } = useLocalSettingsStore();
const openAuth = (isLogin) => {
if (isLogin) {
emitter.emit('login');
} else {
emitter.emit('join');
}
};
return ( return (
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}> <Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
<SheetContent side="left" className="w-50 overflow-y-auto"> <SheetContent side="left" className="w-[220px] overflow-y-auto">
<SheetHeader> <SheetHeader>
<SheetTitle className="flex items-center gap-3"> <SheetTitle className="flex items-center gap-3">
<div className="font-display ml-0 text-lg tracking-wide md:ml-3 md:text-xl">MEMEAIGEN</div> <div className="font-display ml-0 text-lg tracking-wide md:ml-3 md:text-xl">MEMEAIGEN</div>
@@ -20,19 +32,42 @@ export default function EditNavSidebar({ isOpen, onClose }) {
<div className="space-y-3"> <div className="space-y-3">
<div className="grid px-2"> <div className="grid px-2">
{/* {!auth.user && <Button variant="outline">Join Now</Button>} {!auth.user && (
{!auth.user && <Button variant="link">Login</Button>} */} <Button
onClick={() => {
openAuth(false);
}}
variant="outline"
>
Sign Up
</Button>
)}
{!auth.user && (
<Button
onClick={() => {
openAuth(true);
}}
variant="link"
>
Login
</Button>
)}
</div> </div>
<div className="grid px-2"> <div className="space-y-3 px-2">
<Button <Link className="text-primary block w-full text-sm underline-offset-4 hover:underline" href={route('home')} as="button">
onClick={() => {
window.location.href = route('home');
}}
variant="link"
>
Home Home
</Button> </Link>
{auth.user && (
<Link
className="text-primary block w-full text-sm underline-offset-4 hover:underline"
method="post"
href={route('logout')}
as="button"
>
Log out
</Link>
)}
</div> </div>
</div> </div>
</SheetContent> </SheetContent>

View File

@@ -0,0 +1,23 @@
import { usePage } from '@inertiajs/react';
import { useEffect } from 'react';
import { toast } from 'sonner';
const FlashMessages = () => {
const { flash } = usePage().props;
useEffect(() => {
if (flash.message) {
toast.success(flash.message);
}
if (flash.error) {
toast.error(flash.error);
}
if (flash.success) {
toast.success(flash.success);
}
}, [flash]);
};
export default FlashMessages;

View File

@@ -1,9 +1,11 @@
import Editor from '@/modules/editor/editor.jsx'; import Editor from '@/modules/editor/editor.jsx';
import FlashMessages from '@/modules/flash/flash-messages';
const Home = () => { const Home = () => {
return ( return (
<div className="min-h-screen bg-neutral-50 dark:bg-neutral-800"> <div className="min-h-screen bg-neutral-50 dark:bg-neutral-800">
<Editor /> <Editor />
<FlashMessages />
</div> </div>
); );
}; };

View File

@@ -15,7 +15,7 @@ export default function Welcome() {
<nav className="flex items-center justify-end gap-4"> <nav className="flex items-center justify-end gap-4">
{auth.user ? ( {auth.user ? (
<Link <Link
href={route('dashboard')} href={route(config('platform.general.authed_route_redirect'))}
className="inline-block rounded-sm border border-[#19140035] px-5 py-1.5 text-sm leading-normal text-[#1b1b18] hover:border-[#1915014a] dark:border-[#3E3E3A] dark:text-[#EDEDEC] dark:hover:border-[#62605b]" className="inline-block rounded-sm border border-[#19140035] px-5 py-1.5 text-sm leading-normal text-[#1b1b18] hover:border-[#1915014a] dark:border-[#3E3E3A] dark:text-[#EDEDEC] dark:hover:border-[#62605b]"
> >
Dashboard Dashboard

View File

@@ -19,7 +19,7 @@
]); ]);
$this->assertAuthenticated(); $this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false)); $response->assertRedirect(route(config('platform.general.authed_route_redirect'), absolute: false));
}); });
test('users can not authenticate with invalid password', function () { test('users can not authenticate with invalid password', function () {

View File

@@ -30,7 +30,7 @@
Event::assertDispatched(Verified::class); Event::assertDispatched(Verified::class);
expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
$response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); $response->assertRedirect(route(config('platform.general.authed_route_redirect'), absolute: false) . '?verified=1');
}); });
test('email is not verified with invalid hash', function () { test('email is not verified with invalid hash', function () {

View File

@@ -17,5 +17,5 @@
]); ]);
$this->assertAuthenticated(); $this->assertAuthenticated();
$response->assertRedirect(route('dashboard', absolute: false)); $response->assertRedirect(route(config('platform.general.authed_route_redirect'), absolute: false));
}); });