functionnality tirage

This commit is contained in:
Nyavokevin 2025-09-09 18:23:20 +03:00
parent 9e5b160166
commit 39c17b44cf
26 changed files with 1407 additions and 309 deletions

View File

@ -0,0 +1,153 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Stripe\Stripe;
use Stripe\Checkout\Session;
use Illuminate\Support\Str;
use App\Models\Payment;
use App\Repositories\CardRepositoryInterface;
class StripeController extends Controller
{
public function __construct(CardRepositoryInterface $cardRepository)
{
$this->cardRepository = $cardRepository;
}
public function createCheckoutSession(Request $request)
{
Stripe::setApiKey(env('STRIPE_SECRET_KEY'));
$count = $request->input('count');
$clientSessionId = Str::uuid();
$priceIds = [
3 => 'price_1S51zxGaZ3yeYkzWYb0wSt4j',
4 => 'price_1S5464GaZ3yeYkzWh8RuJfab',
];
if (!isset($priceIds[$count])) {
return response()->json(['error' => 'Invalid product selected.'], 400);
}
try {
$session = Session::create([
'line_items' => [[
'price' => $priceIds[$count],
'quantity' => 1,
]],
'mode' => 'payment',
'success_url' => url(env('APP_URL') . '/success?client_session_id=' . $clientSessionId),
'cancel_url' => url(env('APP_URL') . '/cancel'),
'metadata' => [
'draw_count' => $request->input('count'),
'client_session_id' => $clientSessionId,
],
]);
Payment::create([
'amount' => $session->amount_total / 100,
'currency' => $session->currency,
'stripe_session_id' => $session->id,
'client_session_id' => $clientSessionId,
'draw_count' => $count,
'status' => 'pending',
]);
return response()->json(['sessionId' => $session->id]);
} catch (\Exception $e) {
\Log::error('Stripe session creation failed: ' . $e->getMessage());
return response()->json(['error' => 'Could not create checkout session.'], 500);
}
}
public function validatePayment(Request $request)
{
$clientSessionId = $request->query('client_session_id');
$payment = Payment::where('client_session_id', $clientSessionId)
->where('status', 'succeeded')
->first();
if ($payment) {
// Si la vérification réussit, retournez le nombre de tirages.
return response()->json([
'success' => true,
'drawCount' => $payment->draw_count,
]);
}
// Si la vérification échoue, retournez une erreur.
return response()->json([
'success' => false,
'message' => 'Paiement non validé.',
], 404);
}
public function getCards(Request $request)
{
$sessionId = $request->query('client_session_id');
if(!$sessionId)
{
$count = $request->query('count');
if($count == 1){
$freeCards = $this->cardRepository->draw(1);
return response()->json([
'success' => true,
'cards' => $freeCards
]);
}
}
// 1. Find the payment record
$payment = Payment::where('client_session_id', $sessionId)->first();
if (!$payment) {
return response()->json(['success' => false, 'message' => 'Payment not found.'], 404);
}
// 2. One-Time Use Check
if ($payment->status === 'processed') {
return response()->json([
'success' => true,
'cards' => $payment->cards,
'message' => 'Cards already drawn for this payment.',
]);
}
// 3. Verify payment status with Stripe
if ($payment->status !== 'succeeded') {
try {
$session = Session::retrieve($sessionId);
if ($session->payment_status !== 'paid' || $session->status !== 'complete') {
return response()->json(['success' => false, 'message' => 'Payment not complete.'], 402);
}
$payment->update(['status' => 'succeeded']);
} catch (\Exception $e) {
\Log::error('Stripe session retrieval failed: ' . $e->getMessage());
return response()->json(['success' => false, 'message' => 'Validation error.'], 500);
}
}
// 4. Securely draw the cards and store them
$drawnCards = $this->cardRepository->draw($payment->draw_count);
$payment->update([
'cards' => $drawnCards,
'status' => 'processed',
]);
return response()->json([
'success' => true,
'cards' => $drawnCards,
]);
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\Payment; // Make sure this is imported
use Illuminate\Http\Request;
use Stripe\Stripe;
use Stripe\Webhook;
use Stripe\Exception\SignatureVerificationException;
use Log; // Import the Log facade
class WebhookController extends Controller
{
public function handleWebhook(Request $request)
{
Stripe::setApiKey(env('STRIPE_SECRET_KEY'));
$payload = $request->getContent();
$signature = $request->header('Stripe-Signature');
$endpointSecret = env('STRIPE_WEBHOOK_SECRET');
try {
$event = Webhook::constructEvent($payload, $signature, $endpointSecret);
} catch (SignatureVerificationException $e) {
Log::error('Stripe webhook signature verification failed: ' . $e->getMessage());
return response()->json(['error' => 'Invalid signature'], 400);
}
try {
// Handle the event
switch ($event->type) {
case 'checkout.session.completed':
$session = $event->data->object;
$drawCount = $session->metadata->draw_count;
$clientSessionId = $session->metadata->client_session_id;
$payment = Payment::where('client_session_id', $clientSessionId)->first();
if ($payment) {
$payment->update([
'status' => 'succeeded',
'draw_count' => $drawCount,
]);
} else {
// Log if no matching payment record is found
Log::warning('No pending payment record found for client_session_id: ' . $clientSessionId);
}
break;
default:
Log::info('Received a non-checkout.session.completed webhook event: ' . $event->type);
break;
}
} catch (\Exception $e) {
// Log any other unexpected errors
Log::error('Stripe webhook processing error: ' . $e->getMessage(), ['exception' => $e]);
return response()->json(['error' => 'Server error'], 500);
}
return response()->json(['status' => 'success'], 200);
}
}

37
app/Models/Payment.php Normal file
View File

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Payment extends Model
{
use HasFactory;
/**
* The attributes that are mass assignable.
*
* @var array
*/
protected $fillable = [
'amount',
'currency',
'stripe_session_id',
'client_session_id',
'draw_count',
'status',
'cards'
];
/**
* The attributes that should be cast to native types.
*
* @var array
*/
protected $casts = [
'amount' => 'decimal:2',
'cards' => 'array',
];
}

View File

@ -10,6 +10,7 @@ use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
@ -21,6 +22,10 @@ return Application::configure(basePath: dirname(__DIR__))
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
]);
$middleware->validateCsrfTokens(except: [
'stripe/*',
]);
})
->withExceptions(function (Exceptions $exceptions) {
//

View File

@ -12,8 +12,10 @@
"php": "^8.2",
"inertiajs/inertia-laravel": "^2.0",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.10.1",
"laravel/wayfinder": "^0.1.9"
"laravel/wayfinder": "^0.1.9",
"stripe/stripe-php": "^17.6"
},
"require-dev": {
"fakerphp/faker": "^1.23",
@ -83,4 +85,4 @@
},
"minimum-stability": "stable",
"prefer-stable": true
}
}

125
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "644df20f057e10d119c33b325af1c6cb",
"content-hash": "7a72790164b9b6dc081f7cbfde7e67d5",
"packages": [
{
"name": "brick/math",
@ -1399,6 +1399,70 @@
},
"time": "2025-07-07T14:17:42+00:00"
},
{
"name": "laravel/sanctum",
"version": "v4.2.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
"reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677",
"reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^11.0|^12.0",
"illuminate/contracts": "^11.0|^12.0",
"illuminate/database": "^11.0|^12.0",
"illuminate/support": "^11.0|^12.0",
"php": "^8.2",
"symfony/console": "^7.0"
},
"require-dev": {
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.0|^10.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^11.3"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sanctum\\SanctumServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sanctum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
"keywords": [
"auth",
"laravel",
"sanctum"
],
"support": {
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
"time": "2025-07-09T19:45:24+00:00"
},
{
"name": "laravel/serializable-closure",
"version": "v2.0.4",
@ -3406,6 +3470,65 @@
},
"time": "2025-06-25T14:20:11+00:00"
},
{
"name": "stripe/stripe-php",
"version": "v17.6.0",
"source": {
"type": "git",
"url": "https://github.com/stripe/stripe-php.git",
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stripe/stripe-php/zipball/a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"php": ">=5.6.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.72.0",
"phpstan/phpstan": "^1.2",
"phpunit/phpunit": "^5.7 || ^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"Stripe\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Stripe and contributors",
"homepage": "https://github.com/stripe/stripe-php/contributors"
}
],
"description": "Stripe PHP Library",
"homepage": "https://stripe.com/",
"keywords": [
"api",
"payment processing",
"stripe"
],
"support": {
"issues": "https://github.com/stripe/stripe-php/issues",
"source": "https://github.com/stripe/stripe-php/tree/v17.6.0"
},
"time": "2025-08-27T19:32:42+00:00"
},
{
"name": "symfony/clock",
"version": "v7.3.0",

84
config/sanctum.php Normal file
View File

@ -0,0 +1,84 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort(),
// Sanctum::currentRequestHost(),
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];

View File

@ -0,0 +1,26 @@
<?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::table('users', function (Blueprint $table) {
$table->integer('paid_draws')->default(1);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$table->dropColumn('paid_draws');
}
};

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::table('payments', function (Blueprint $table) {
$table->integer('draw_count')->nullable()->after('status');
$table->string('client_session_id', 255)->nullable()->after('stripe_session_id');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('payments', function (Blueprint $table) {
$table->dropColumn(['draw_count', 'client_session_id']);
});
}
};

View File

@ -0,0 +1,33 @@
<?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('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};

View File

@ -0,0 +1,36 @@
<?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::table('payments', function (Blueprint $table) {
// Modify the status enum to include 'processed'
$table->enum('status', ['pending', 'succeeded', 'failed', 'refunded', 'processed'])->default('pending')->change();
// Add the cards JSON column
$table->json('cards')->nullable()->after('status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('payments', function (Blueprint $table) {
// Revert the status enum to its original values
$table->enum('status', ['pending', 'succeeded', 'failed', 'refunded'])->default('pending')->change();
// Remove the cards column
$table->dropColumn('cards');
});
}
};

104
package-lock.json generated
View File

@ -6,6 +6,7 @@
"": {
"dependencies": {
"@inertiajs/vue3": "^2.1.0",
"@stripe/stripe-js": "^7.9.0",
"@vue-stripe/vue-stripe": "^4.5.0",
"@vueuse/core": "^12.8.2",
"axios": "^1.11.0",
@ -14,11 +15,15 @@
"laravel-vite-plugin": "^2.0.0",
"lucide-vue-next": "^0.468.0",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0",
"reka-ui": "^2.2.0",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.1",
"tw-animate-css": "^1.2.5",
"vue": "^3.5.13"
"uuid": "^12.0.0",
"vue": "^3.5.13",
"vue-router": "^4.5.1",
"vue-stripe-js": "^2.0.2"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
@ -1227,9 +1232,12 @@
]
},
"node_modules/@stripe/stripe-js": {
"version": "1.54.2",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-1.54.2.tgz",
"integrity": "sha512-R1PwtDvUfs99cAjfuQ/WpwJ3c92+DAMy9xGApjqlWQMj0FKQabUAys2swfTRNzuYAYJh7NqK2dzcYVNkKLEKUg=="
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"engines": {
"node": ">=12.16"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.17",
@ -1871,6 +1879,11 @@
"vue-coerce-props": "^1.0.0"
}
},
"node_modules/@vue-stripe/vue-stripe/node_modules/@stripe/stripe-js": {
"version": "1.54.2",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-1.54.2.tgz",
"integrity": "sha512-R1PwtDvUfs99cAjfuQ/WpwJ3c92+DAMy9xGApjqlWQMj0FKQabUAys2swfTRNzuYAYJh7NqK2dzcYVNkKLEKUg=="
},
"node_modules/@vue/compiler-core": {
"version": "3.5.18",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.18.tgz",
@ -2512,6 +2525,11 @@
"dev": true,
"license": "MIT"
},
"node_modules/deep-pick-omit": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/deep-pick-omit/-/deep-pick-omit-1.2.1.tgz",
"integrity": "sha512-2J6Kc/m3irCeqVG42T+SaUMesaK7oGWaedGnQQK/+O0gYc+2SP5bKh/KKTE7d7SJ+GCA9UUE1GRzh6oDe0EnGw=="
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
@ -2527,6 +2545,11 @@
"node": ">=0.4.0"
}
},
"node_modules/destr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
@ -4155,6 +4178,32 @@
}
}
},
"node_modules/pinia-plugin-persistedstate": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/pinia-plugin-persistedstate/-/pinia-plugin-persistedstate-4.5.0.tgz",
"integrity": "sha512-QTkP1xJVyCdr2I2p3AKUZM84/e+IS+HktRxKGAIuDzkyaKKV48mQcYkJFVVDuvTxlI5j6X3oZObpqoVB8JnWpw==",
"dependencies": {
"deep-pick-omit": "^1.2.1",
"defu": "^6.1.4",
"destr": "^2.0.5"
},
"peerDependencies": {
"@nuxt/kit": ">=3.0.0",
"@pinia/nuxt": ">=0.10.0",
"pinia": ">=3.0.0"
},
"peerDependenciesMeta": {
"@nuxt/kit": {
"optional": true
},
"@pinia/nuxt": {
"optional": true
},
"pinia": {
"optional": true
}
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -4956,6 +5005,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-12.0.0.tgz",
"integrity": "sha512-USe1zesMYh4fjCA8ZH5+X5WIVD0J4V1Jksm1bFTVBX2F/cwSXt0RO5w/3UXbdLKmZX65MiWV+hwhSS8p6oBTGA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vite": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz",
@ -5139,6 +5200,41 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/vue-router": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.1.tgz",
"integrity": "sha512-ogAF3P97NPm8fJsE4by9dwSYtDwXIY1nFY9T6DyQnGHd1E2Da94w9JIolpe42LJGIl0DwOHBi8TcRPlPGwbTtw==",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/vue-router/node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
},
"node_modules/vue-stripe-js": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/vue-stripe-js/-/vue-stripe-js-2.0.2.tgz",
"integrity": "sha512-tYDUw0zzXfo7kTTyTAFYCDZP7BPq1TM5mAgCRcjcLg7IgUiJlSBcnReDoCCZ6RDhSjFC4Yl5hwKO5Zs38fV63A==",
"dependencies": {
"@stripe/stripe-js": "^5.5.0"
}
},
"node_modules/vue-stripe-js/node_modules/@stripe/stripe-js": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-5.10.0.tgz",
"integrity": "sha512-PTigkxMdMUP6B5ISS7jMqJAKhgrhZwjprDqR1eATtFfh0OpKVNp110xiH+goeVdrJ29/4LeZJR4FaHHWstsu0A==",
"engines": {
"node": ">=12.16"
}
},
"node_modules/vue-tsc": {
"version": "2.2.12",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz",

View File

@ -30,6 +30,7 @@
},
"dependencies": {
"@inertiajs/vue3": "^2.1.0",
"@stripe/stripe-js": "^7.9.0",
"@vue-stripe/vue-stripe": "^4.5.0",
"@vueuse/core": "^12.8.2",
"axios": "^1.11.0",
@ -38,11 +39,15 @@
"laravel-vite-plugin": "^2.0.0",
"lucide-vue-next": "^0.468.0",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.5.0",
"reka-ui": "^2.2.0",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.1",
"tw-animate-css": "^1.2.5",
"vue": "^3.5.13"
"uuid": "^12.0.0",
"vue": "^3.5.13",
"vue-router": "^4.5.1",
"vue-stripe-js": "^2.0.2"
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.9.5",

View File

@ -3,12 +3,14 @@ import '../css/app.css';
import { createInertiaApp } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import type { DefineComponent } from 'vue';
import { createApp, h } from 'vue';
import { initializeTheme } from './composables/useAppearance';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
createInertiaApp({
title: (title) => (title ? `${title} - ${appName}` : appName),

View File

@ -1,3 +1,11 @@
<script lang="ts" setup>
import { router } from '@inertiajs/vue3';
const goToShuffle = () => {
router.visit('/tirage');
};
</script>
<template>
<div class="relative flex items-center justify-center py-20 text-center sm:py-32">
<div class="absolute inset-0 overflow-hidden">
@ -22,6 +30,7 @@
</p>
<button
class="mt-4 flex h-12 max-w-[480px] min-w-[120px] cursor-pointer items-center justify-center overflow-hidden rounded-full bg-[var(--subtle-gold)] px-8 text-base font-bold tracking-wide text-[var(--midnight-blue)] transition-all duration-300 hover:shadow-[var(--subtle-gold)]/30 hover:shadow-lg"
@click="goToShuffle"
>
<span class="truncate">Découvrir Votre Oracle</span>
</button>

View File

@ -1,100 +1,58 @@
<script setup lang="ts">
import CardShuffleTemplate from '@/components/template/CardShuffleTemplate.vue';
import { Card } from '@/types/cart';
import { ref, watch } from 'vue';
import { router } from '@inertiajs/vue3';
import { ref, watchEffect } from 'vue';
const emit = defineEmits(['drawCard']);
const props = defineProps<{
drawCount?: number; // Optional prop for the shuffle animation
drawnCards?: Card[]; // Optional prop to directly display cards
}>();
defineEmits(['drawCard']);
const isClicked = ref(false);
const isDrawing = ref(false);
const drawnCards = ref<Card[]>([]); // Changed to array to handle multiple cards
const showResult = ref(false);
const isFlipped = ref<boolean[]>([]); // Array to track flip state for each card
const isFlipped = ref<boolean[]>([]);
const handleClick = () => {
if (isDrawing.value) return;
isClicked.value = true;
isDrawing.value = true;
emit('drawCard');
setTimeout(() => (isClicked.value = false), 500);
setTimeout(() => {
showResult.value = true;
}, 800);
};
// This function would be called from the parent component when the card data is received
// This function is still needed for when the user clicks to draw on the /tirage page
const setDrawnCards = (cardData: Card[]) => {
drawnCards.value = cardData;
isDrawing.value = false;
showResult.value = true;
// Initialize flip states for each card
isFlipped.value = new Array(cardData.length).fill(false);
// Add confetti effect
createConfetti();
// This method is called by the parent component after an API call
if (cardData) {
// Here, we assign the cards to a local state to be displayed
showResult.value = true;
isDrawing.value = false;
isFlipped.value = new Array(cardData.length).fill(false);
// The confetti and card data will be handled by the component that consumes this one
}
};
watch(drawnCards, (newVal) => {
console.log('Drawn cards:', newVal);
});
const flipCard = (index: number) => {
isFlipped.value[index] = !isFlipped.value[index];
};
const createConfetti = () => {
const confettiContainer = document.createElement('div');
confettiContainer.style.position = 'fixed';
confettiContainer.style.top = '0';
confettiContainer.style.left = '0';
confettiContainer.style.width = '100%';
confettiContainer.style.height = '100%';
confettiContainer.style.pointerEvents = 'none';
confettiContainer.style.zIndex = '5'; // Lower z-index so cards appear above
document.body.appendChild(confettiContainer);
const colors = ['#D7BA8D', '#A06D52', '#1F2A44', '#FFFFFF'];
const confettiCount = 100;
for (let i = 0; i < confettiCount; i++) {
const confetti = document.createElement('div');
confetti.style.position = 'absolute';
confetti.style.width = '10px';
confetti.style.height = '10px';
confetti.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)];
confetti.style.borderRadius = Math.random() > 0.5 ? '50%' : '0';
confetti.style.top = '50%';
confetti.style.left = '50%';
confetti.style.opacity = '0';
confettiContainer.appendChild(confetti);
const animation = confetti.animate(
[
{
transform: 'translate(0, 0) rotate(0deg)',
opacity: 1,
},
{
transform: `translate(${Math.random() * 400 - 200}px, ${Math.random() * 400 - 200}px) rotate(${Math.random() * 360}deg)`,
opacity: 0,
},
],
{
duration: 1000 + Math.random() * 1000,
easing: 'cubic-bezier(0.1, 0.8, 0.3, 1)',
},
);
animation.onfinish = () => {
confetti.remove();
if (confettiContainer.children.length === 0) {
confettiContainer.remove();
}
};
// Use watchEffect to react to the `drawnCards` prop changing
watchEffect(() => {
if (props.drawnCards && props.drawnCards.length > 0) {
showResult.value = false;
isFlipped.value = new Array(props.drawnCards.length).fill(false);
// The confetti and other logic is also triggered here.
} else {
showResult.value = false;
}
});
const goToSelection = () => {
router.visit('/tirage');
};
// Expose the setDrawnCards function to parent component
defineExpose({ setDrawnCards });
</script>
@ -102,98 +60,16 @@ defineExpose({ setDrawnCards });
<CardShuffleTemplate>
<template #card-shuffle-slot>
<div class="card-container">
<div
class="card-stack relative mt-4 mb-4 flex h-[500px] w-[300px] items-center justify-center"
:class="{ clicked: isClicked, drawing: isDrawing }"
@click="handleClick"
>
<div class="card" style="transform: rotate(-3deg) translateZ(0); z-index: 3">
<div class="card-back">
<div class="card-back-design">
<svg
class="h-16 w-16 text-[var(--subtle-gold)] opacity-80"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 4v16m8-8H4" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"></path>
<path
d="M14.828 7.172a4 4 0 015.656 5.656l-5.656 5.657a4 4 0 01-5.657-5.657l5.657-5.656z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="0.5"
></path>
<path
d="M9.172 7.172a4 4 0 00-5.657 5.656l5.657 5.657a4 4 0 005.656-5.657L9.172 7.172z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="0.5"
></path>
</svg>
</div>
</div>
</div>
<div class="card" style="transform: rotate(1deg) translateZ(-10px); z-index: 2">
<div class="card-back">
<div class="card-back-design">
<svg
class="h-16 w-16 text-[var(--subtle-gold)] opacity-80"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 4v16m8-8H4" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"></path>
<path
d="M14.828 7.172a4 4 0 015.656 5.656l-5.656 5.657a4 4 0 01-5.657-5.657l5.657-5.656z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="0.5"
></path>
<path
d="M9.172 7.172a4 4 0 00-5.657 5.656l5.657 5.657a4 4 0 005.656-5.657L9.172 7.172z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="0.5"
></path>
</svg>
</div>
</div>
</div>
<div class="card" style="transform: rotate(4deg) translateZ(-20px); z-index: 1">
<div class="card-back">
<div class="card-back-design">
<svg
class="h-16 w-16 text-[var(--subtle-gold)] opacity-80"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 4v16m8-8H4" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"></path>
<path
d="M14.828 7.172a4 4 0 015.656 5.656l-5.656 5.657a4 4 0 01-5.657-5.657l5.657-5.656z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="0.5"
></path>
<path
d="M9.172 7.172a4 4 0 00-5.657 5.656l5.657 5.657a4 4 0 005.656-5.657L9.172 7.172z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="0.5"
></path>
</svg>
</div>
</div>
</div>
</div>
<div v-if="showResult && drawnCards.length" class="cards-result-container">
<div v-for="(card, index) in drawnCards" :key="index" class="card-result-wrapper">
<div class="result-card" :class="{ flipped: isFlipped[index] }" @click="flipCard(index)">
<div class="card-face card-unknown-front">
<!-- Always show the card stack -->
<transition class="card-stack-fade">
<div
class="card-stack relative mt-4 mb-4 flex h-[500px] w-[300px] items-center justify-center"
:class="{ clicked: isClicked, drawing: isDrawing }"
@click="handleClick"
v-show="!showResult"
>
<div v-for="i in 8" :key="i" class="card" :style="{ transform: `rotate(${-3 + i}deg) translateZ(-${10 * i}px);` }">
<div class="card-back">
<div class="card-back-design">
<svg
class="h-16 w-16 text-[var(--subtle-gold)] opacity-80"
@ -218,26 +94,64 @@ defineExpose({ setDrawnCards });
</svg>
</div>
</div>
<div class="card-face card-known-back">
<img :src="card.image_url!" :alt="card.name" class="card-image" />
<div class="card-description-overlay">
<h3>{{ card.name }}</h3>
<p class="description">{{ card.description }}</p>
<p v-if="card.orientation" class="orientation">
{{ card.orientation === 'reversed' ? 'Inversée' : 'Droite' }}
</p>
<div v-if="card.symbolism" class="symbolism">
<p><strong>Numéro:</strong> {{ card.symbolism.numéro }}</p>
<p><strong>Planète:</strong> {{ card.symbolism.planète }}</p>
<p><strong>Élément:</strong> {{ card.symbolism.élément }}</p>
</div>
</div>
</transition>
<!-- Show result only after clicking and when drawnCards is available -->
<transition class="card-result-slide">
<div v-if="showResult && drawnCards" class="cards-result-container">
<div v-for="(card, index) in drawnCards" :key="index" class="card-result-wrapper">
<div class="result-card" :class="{ flipped: isFlipped[index] }" @click="flipCard(index)">
<div class="card-face card-unknown-front">
<div class="card-back-design">
<svg
class="h-16 w-16 text-[var(--subtle-gold)] opacity-80"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 4v16m8-8H4" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"></path>
<path
d="M14.828 7.172a4 4 0 015.656 5.656l-5.656 5.657a4 4 0 01-5.657-5.657l5.657-5.656z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="0.5"
></path>
<path
d="M9.172 7.172a4 4 0 00-5.657 5.656l5.657 5.657a4 4 0 005.656-5.657L9.172 7.172z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="0.5"
></path>
</svg>
</div>
</div>
<div class="card-face card-known-back">
<img :src="card.image_url!" :alt="card.name" class="card-image" />
<div class="card-description-overlay">
<h3>{{ card.name }}</h3>
<p class="description">{{ card.description }}</p>
<p v-if="card.orientation" class="orientation">
{{ card.orientation === 'reversed' ? 'Inversée' : 'Droite' }}
</p>
<div v-if="card.symbolism" class="symbolism">
<p><strong>Numéro:</strong> {{ card.symbolism.numéro }}</p>
<p><strong>Planète:</strong> {{ card.symbolism.planète }}</p>
<p><strong>Élément:</strong> {{ card.symbolism.élément }}</p>
</div>
</div>
<p class="click-hint">Cliquez pour retourner</p>
</div>
</div>
</div>
<button
@click="goToSelection"
class="mt-8 flex h-12 max-w-[480px] min-w-[200px] cursor-pointer items-center justify-center overflow-hidden rounded-full bg-[var(--midnight-blue)] px-8 text-base font-bold tracking-wide text-[var(--pure-white)] transition-all duration-300 hover:bg-[var(--spiritual-earth)] hover:shadow-[var(--spiritual-earth)]/30 hover:shadow-lg disabled:cursor-not-allowed disabled:bg-gray-400 disabled:hover:shadow-none"
>
<span class="truncate">Retourner à la sélection des cartes</span>
</button>
</div>
</div>
</transition>
</div>
</template>
</CardShuffleTemplate>
@ -560,4 +474,21 @@ defineExpose({ setDrawnCards });
font-size: 0.7rem;
opacity: 0.7;
}
.card-stack-fade-leave-active {
transition: all 0.8s ease;
}
.card-stack-fade-leave-to {
opacity: 0;
transform: translateY(-50px) scale(0.9);
}
.cards-result-slide-enter-active {
transition: all 0.8s ease;
transition-delay: 0.3s;
}
.cards-result-slide-enter-from {
opacity: 0;
transform: translateY(50px);
}
</style>

View File

@ -0,0 +1,103 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
const props = defineProps<{
show: boolean;
delay: number; // Delay in milliseconds before the animation starts
}>();
const isVisible = ref(false);
watch(
() => props.show,
(newValue) => {
if (newValue) {
setTimeout(() => {
isVisible.value = true;
}, props.delay);
} else {
isVisible.value = false;
}
},
);
</script>
<template>
<div v-if="isVisible" class="companion-container">
<div class="animated-element">
<svg
class="w-32 h-32 text-[var(--subtle-gold)] animate-pulse"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
></path>
</svg>
</div>
<p class="companion-text">Votre lecture est prête...</p>
</div>
</template>
<style scoped>
.companion-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
z-index: 20; /* Ensure it's on top of other elements */
opacity: 0;
animation: fadeInScale 0.8s ease-out forwards;
}
.animated-element {
animation: float 2s ease-in-out infinite;
}
.companion-text {
font-size: 1.5rem;
font-weight: bold;
color: var(--subtle-gold);
animation: pulseText 2s ease-in-out infinite;
}
@keyframes fadeInScale {
from {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes pulseText {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
</style>

View File

@ -1,7 +1,20 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { ref } from 'vue';
const isMobileMenuOpen = ref(false);
const toggleMobileMenu = () => {
isMobileMenuOpen.value = !isMobileMenuOpen.value;
};
</script>
<template>
<div class="group/design-root relative flex size-full min-h-screen flex-col overflow-x-hidden">
<div class="layout-container flex h-full grow flex-col">
<header class="bg-light-ivory/80 sticky top-0 z-50 flex items-center justify-between px-10 py-4 whitespace-nowrap backdrop-blur-sm">
<header
class="bg-light-ivory/80 sticky top-0 z-50 flex items-center justify-between px-4 py-4 whitespace-nowrap backdrop-blur-sm md:px-10 lg:px-20"
>
<div class="text-midnight-blue flex items-center gap-3">
<svg class="text-subtle-gold h-6 w-6" fill="none" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_6_535)">
@ -18,23 +31,71 @@
</clipPath>
</defs>
</svg>
<h2 class="text-2xl font-bold tracking-wide">Kris Saint Ange</h2>
<h2 class="text-xl font-bold tracking-wide md:text-2xl">Kris Saint Ange</h2>
</div>
<nav class="flex flex-1 items-center justify-center gap-10">
<a class="text-midnight-blue hover:text-subtle-gold text-sm font-medium transition-colors duration-300" href="#">Accueil</a>
<a class="text-midnight-blue hover:text-subtle-gold text-sm font-medium transition-colors duration-300" href="#">L'Oracle</a>
<a class="text-midnight-blue hover:text-subtle-gold text-sm font-medium transition-colors duration-300" href="#">Lectures</a>
<a class="text-midnight-blue hover:text-subtle-gold text-sm font-medium transition-colors duration-300" href="#">Témoignages</a>
<a class="text-midnight-blue hover:text-subtle-gold text-sm font-medium transition-colors duration-300" href="#">Contact</a>
<nav class="hidden flex-1 items-center justify-center gap-10 md:flex">
<Link class="text-midnight-blue hover:text-subtle-gold text-sm font-medium transition-colors duration-300" href="/">Accueil</Link>
<Link class="text-midnight-blue hover:text-subtle-gold text-sm font-medium transition-colors duration-300" href="#"
>L'Oracle</Link
>
<Link class="text-midnight-blue hover:text-subtle-gold text-sm font-medium transition-colors duration-300" href="#"
>Lectures</Link
>
<Link class="text-midnight-blue hover:text-subtle-gold text-sm font-medium transition-colors duration-300" href="#"
>Témoignages</Link
>
<Link class="text-midnight-blue hover:text-subtle-gold text-sm font-medium transition-colors duration-300" href="#">Contact</Link>
</nav>
<div class="flex items-center gap-4">
<button @click="toggleMobileMenu" class="md:hidden">
<svg class="text-midnight-blue h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7"></path>
</svg>
</button>
<button
class="bg-midnight-blue text-pure-white hover:bg-spiritual-earth flex h-10 max-w-[480px] min-w-[100px] cursor-pointer items-center justify-center overflow-hidden rounded-full px-6 text-sm font-bold tracking-wide transition-all duration-300"
v-if="$page.url === '/'"
class="bg-midnight-blue text-pure-white hover:bg-spiritual-earth flex h-10 min-w-[100px] cursor-pointer items-center justify-center overflow-hidden rounded-full px-4 text-sm font-bold tracking-wide transition-all duration-300 md:px-6"
>
<span class="truncate">Commencer</span>
</button>
</div>
</header>
<nav v-if="isMobileMenuOpen" class="bg-light-ivory/90 flex flex-col items-center gap-4 py-4 md:hidden">
<Link
class="text-midnight-blue hover:text-subtle-gold text-sm font-medium transition-colors duration-300"
href="#"
@click="toggleMobileMenu"
>Accueil</Link
>
<Link
class="text-midnight-blue hover:text-subtle-gold text-sm font-medium transition-colors duration-300"
href="#"
@click="toggleMobileMenu"
>L'Oracle</Link
>
<Link
class="text-midnight-blue hover:text-subtle-gold text-sm font-medium transition-colors duration-300"
href="#"
@click="toggleMobileMenu"
>Lectures</Link
>
<Link
class="text-midnight-blue hover:text-subtle-gold text-sm font-medium transition-colors duration-300"
href="#"
@click="toggleMobileMenu"
>Témoignages</Link
>
<Link
class="text-midnight-blue hover:text-subtle-gold text-sm font-medium transition-colors duration-300"
href="#"
@click="toggleMobileMenu"
>Contact</Link
>
</nav>
<main class="flex flex-col items-center">
<slot />
@ -43,37 +104,37 @@
<footer class="bg-pure-white text-midnight-blue/80 py-12">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="flex flex-col items-center gap-8">
<div class="flex flex-wrap justify-center gap-x-8 gap-y-4">
<a class="hover:text-subtle-gold transition-colors" href="#">Accueil</a>
<a class="hover:text-subtle-gold transition-colors" href="#">L'Oracle</a>
<a class="hover:text-subtle-gold transition-colors" href="#">Lectures</a>
<a class="hover:text-subtle-gold transition-colors" href="#">Témoignages</a>
<a class="hover:text-subtle-gold transition-colors" href="#">Contact</a>
<a class="hover:text-subtle-gold transition-colors" href="#">Politique de Confidentialité</a>
<a class="hover:text-subtle-gold transition-colors" href="#">Conditions d'Utilisation</a>
<div class="flex flex-wrap justify-center gap-x-4 gap-y-2 md:gap-x-8 md:gap-y-4">
<Link class="hover:text-subtle-gold transition-colors" href="#">Accueil</Link>
<Link class="hover:text-subtle-gold transition-colors" href="#">L'Oracle</Link>
<Link class="hover:text-subtle-gold transition-colors" href="#">Lectures</Link>
<Link class="hover:text-subtle-gold transition-colors" href="#">Témoignages</Link>
<Link class="hover:text-subtle-gold transition-colors" href="#">Contact</Link>
<Link class="hover:text-subtle-gold transition-colors" href="#">Politique de Confidentialité</Link>
<Link class="hover:text-subtle-gold transition-colors" href="#">Conditions d'Utilisation</Link>
</div>
<div class="flex justify-center gap-6">
<a class="text-midnight-blue/60 hover:text-spiritual-earth transition-colors" href="#">
<div class="flex justify-center gap-4 md:gap-6">
<Link class="text-midnight-blue/60 hover:text-spiritual-earth transition-colors" href="#">
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 256 256">
<path
d="M128,80a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160ZM176,24H80A56.06,56.06,0,0,0,24,80v96a56.06,56.06,0,0,0,56,56h96a56.06,56.06,0,0,0,56-56V80A56.06,56.06,0,0,0,176,24Zm40,152a40,40,0,0,1-40,40H80a40,40,0,0,1-40-40V80A40,40,0,0,1,80,40h96a40,40,0,0,1,40,40ZM192,76a12,12,0,1,1-12-12A12,12,0,0,1,192,76Z"
></path>
</svg>
</a>
<a class="text-midnight-blue/60 hover:text-spiritual-earth transition-colors" href="#">
</Link>
<Link class="text-midnight-blue/60 hover:text-spiritual-earth transition-colors" href="#">
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 256 256">
<path
d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm8,191.63V152h24a8,8,0,0,0,0-16H136V112a16,16,0,0,1,16-16h16a8,8,0,0,0,0-16H152a32,32,0,0,0-32,32v24H96a8,8,0,0,0,0,16h24v63.63a88,88,0,1,1,16,0Z"
></path>
</svg>
</a>
<a class="text-midnight-blue/60 hover:text-spiritual-earth transition-colors" href="#">
</Link>
<Link class="text-midnight-blue/60 hover:text-spiritual-earth transition-colors" href="#">
<svg class="h-6 w-6" fill="currentColor" viewBox="0 0 256 256">
<path
d="M247.39,68.94A8,8,0,0,0,240,64H209.57A48.66,48.66,0,0,0,168.1,40a46.91,46.91,0,0,0-33.75,13.7A47.9,47.9,0,0,0,120,88v6.09C79.74,83.47,46.81,50.72,46.46,50.37a8,8,0,0,0-13.65,4.92c-4.31,47.79,9.57,79.77,22,98.18a110.93,110.93,0,0,0,21.88,24.2c-15.23,17.53-39.21,26.74-39.47,26.84a8,8,0,0,0-3.85,11.93c.75,1.12,3.75,5.05,11.08,8.72C53.51,229.7,65.48,232,80,232c70.67,0,129.72-54.42,135.75-124.44l29.91-29.9A8,8,0,0,0,247.39,68.94Zm-45,29.41a8,8,0,0,0-2.32,5.14C196,166.58,143.28,216,80,216c-10.56,0-18-1.4-23.22-3.08,11.51-6.25,27.56-17,37.88-32.48A8,8,0,0,0,92,169.08c-.47-.27-43.91-26.34-44-96,16,13,45.25,33.17,78.67,38.79A8,8,0,0,0,136,104V88a32,32,0,0,1,9.6-22.92A30.94,30.94,0,0,1,167.9,56c12.66.16,24.49,7.88,29.44,19.21A8,8,0,0,0,204.67,80h16Z"
></path>
</svg>
</a>
</Link>
</div>
<p class="text-sm">© 2024 Kris Saint Ange. Tous droits réservés.</p>
</div>
@ -82,6 +143,7 @@
</div>
</div>
</template>
<style scoped>
/* Utility classes for colors */
.text-midnight-blue {

View File

View File

@ -0,0 +1,121 @@
<script setup lang="ts">
import { useTarotStore } from '@/stores/tarot';
import { loadStripe } from '@stripe/stripe-js';
import axios from 'axios';
import { computed, ref } from 'vue';
const tarotStore = useTarotStore();
const isSelectionScreen = ref(true);
const loading = ref(false);
const drawCount = ref(0);
// Emits the draw selection back to parent
const emit = defineEmits<{
(e: 'selectDraw', count: number): void;
}>();
const handleSelection = async (count: number) => {
drawCount.value = count;
if (count == 1 && tarotStore.freeDrawsRemaining <= 0) {
alert('You have used your free draw. Please choose a paid option to unlock more.');
return;
}
if (count > 1 && tarotStore.paidDrawsRemaining < count) {
await redirectToStripeCheckout(count);
return;
}
emit('selectDraw', count);
};
const redirectToStripeCheckout = async (count: number) => {
loading.value = true;
try {
// 1. Send request to your Laravel backend to create a Stripe Checkout Session
const res = await axios.post('/create-checkout-session', { count });
const { sessionId } = res.data;
// 2. Load Stripe.js with your publishable key
const stripe = await loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY!);
// 3. Redirect to Stripe Checkout using the session ID from the backend
if (stripe) {
const { error } = await stripe.redirectToCheckout({ sessionId });
if (error) {
console.error('Stripe redirect error:', error.message);
alert('Payment failed. Please try again.');
}
}
} catch (error) {
console.error('Error initiating Stripe checkout:', error);
alert('Payment processing failed. Please try again.');
} finally {
loading.value = false;
}
};
// Computed to disable the free draw button if used
const isFreeDrawUsed = computed(() => tarotStore.freeDrawsRemaining <= 0);
</script>
<template>
<section class="py-20 sm:py-24">
<h2 class="mb-16 text-center text-4xl font-bold text-[var(--midnight-blue)] md:text-5xl">Explorez Nos Lectures</h2>
<p v-if="isFreeDrawUsed" class="mb-8 text-center text-lg font-semibold text-[var(--spiritual-earth)]">
Vous avez utilisé votre tirage gratuit ! Choisissez une option payante pour continuer.
</p>
<div class="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
<!-- Free draw -->
<div
class="flex flex-col gap-6 rounded-2xl border border-[var(--linen)] bg-[var(--pure-white)] p-8 shadow-lg transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl"
>
<div class="text-center">
<h3 class="text-2xl font-bold text-[var(--midnight-blue)]">Lecture Gratuite</h3>
<p class="mt-2 text-5xl font-bold text-[var(--subtle-gold)]">Gratuit</p>
</div>
<button
:disabled="isFreeDrawUsed"
class="mt-4 flex h-12 w-full items-center justify-center rounded-full bg-[var(--linen)] px-8 font-bold tracking-wide text-[var(--midnight-blue)] transition-all duration-300 hover:bg-[var(--spiritual-earth)] hover:text-[var(--pure-white)] disabled:cursor-not-allowed disabled:opacity-50"
@click="handleSelection(1)"
>
Commencer
</button>
</div>
<!-- Paid options -->
<div
class="flex scale-105 flex-col gap-6 rounded-2xl bg-[var(--midnight-blue)] p-8 shadow-lg ring-2 ring-[var(--subtle-gold)] transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl"
>
<div class="text-center">
<h3 class="text-2xl font-bold text-[var(--pure-white)]">Profilage</h3>
<p class="mt-2 text-5xl font-bold text-[var(--subtle-gold)]">29</p>
</div>
<button
class="mt-4 flex h-12 w-full items-center justify-center rounded-full bg-[var(--subtle-gold)] px-8 font-bold tracking-wide text-[var(--midnight-blue)] transition-all duration-300 hover:bg-[var(--pure-white)]"
@click="handleSelection(3)"
>
Découvrir
</button>
</div>
<div
class="flex flex-col gap-6 rounded-2xl border border-[var(--linen)] bg-[var(--pure-white)] p-8 shadow-lg transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl"
>
<div class="text-center">
<h3 class="text-2xl font-bold text-[var(--midnight-blue)]">Quadrige Doré</h3>
<p class="mt-2 text-5xl font-bold text-[var(--subtle-gold)]">99</p>
</div>
<button
class="mt-4 flex h-12 w-full items-center justify-center rounded-full bg-[var(--linen)] px-8 font-bold tracking-wide text-[var(--midnight-blue)] transition-all duration-300 hover:bg-[var(--spiritual-earth)] hover:text-[var(--pure-white)]"
@click="handleSelection(4)"
>
Explorer
</button>
</div>
</div>
</section>
</template>

View File

@ -3,104 +3,115 @@ import ShuffleCardPresentation from '@/components/organism/ShuffleCard/ShuffleCa
import LandingLayout from '@/layouts/app/LandingLayout.vue';
import { useTarotStore } from '@/stores/tarot';
import axios from 'axios';
import { ref } from 'vue';
import { onMounted, ref, watchEffect } from 'vue'; // Import watchEffect
import cardSelection from './cardSelection.vue';
const cardComponent = ref();
const tarotStore = useTarotStore();
const isSelectionScreen = ref(true);
const loading = ref(false);
const error = ref<string | null>(null);
// This variable will hold the number of cards to draw
const drawCount = ref(0);
const cards = ref([]);
// This function will be called from the "offer" buttons
const handleSelection = (count: number) => {
const params = new URLSearchParams(window.location.search);
const clientSessionId = params.get('client_session_id');
// Use watchEffect to handle state changes from Pinia
watchEffect(() => {
// Check if there are paid draws available and no free draws, and transition automatically
if (tarotStore.paidDrawsRemaining > 0 && tarotStore.freeDrawsRemaining === 0) {
isSelectionScreen.value = false;
drawCount.value = tarotStore.paidDrawsRemaining; // Set the drawCount to the available paid draws
}
});
onMounted(async () => {
if (clientSessionId) {
try {
const response = await axios.get(`/api/get-cards?client_session_id=${clientSessionId}`);
if (response.data.success) {
cards.value = response.data.cards;
if (cardComponent.value) {
cardComponent.value.setDrawnCards(cards.value);
}
console.log(cards.value);
} else {
error.value = response.data.message || 'An error occurred while validating payment.';
}
} catch (err) {
console.error(err);
error.value = 'Failed to get cards from the server. Please contact support.';
} finally {
loading.value = false;
}
}
});
const handleSelection = async (count: number) => {
drawCount.value = count;
loading.value = true;
error.value = null;
// Check if the draw is free or requires payment
// Check if it's a free draw and there's one available
if (count === 1) {
// Free draw
if (tarotStore.freeDrawsRemaining > 0) {
tarotStore.useFreeDraw();
isSelectionScreen.value = false; // Switch to the shuffle screen
try {
// Make the API call for the free draw
const response = await axios.get('/api/get-cards', {
params: { count: 1 },
});
if (response.data.success) {
cards.value = response.data.cards;
// Only use the free draw after a successful API call
tarotStore.useFreeDraw();
isSelectionScreen.value = false;
} else {
error.value = response.data.message || 'An error occurred while getting cards.';
}
} catch (err) {
console.error(err);
error.value = 'Failed to get cards from the server. Please contact support.';
} finally {
loading.value = false;
}
} else {
alert('You have used your free draw. Please choose a paid option to unlock more.');
alert("You don't have any free draws left. Please choose a paid option.");
loading.value = false;
}
} else {
// Paid draw
// This is where you'd trigger your Stripe payment component
alert(`Initiating payment process for a ${count}-card draw.`);
// For now, let's simulate a successful payment and then proceed
tarotStore.unlockNewDraws().then(() => {
isSelectionScreen.value = false;
});
}
};
const getCard = async () => {
loading.value = true;
try {
const res = await axios.post('/draw-card', { count: drawCount.value });
if (res.data) {
cardComponent.value.setDrawnCards(res.data.card);
// This is the paid draw logic, which remains mostly the same
if (tarotStore.paidDrawsRemaining >= count) {
// Here, you would also make an API call to get the paid cards
try {
const response = await axios.get('/api/get-cards', {
params: { count: count },
});
if (response.data.success) {
cards.value = response.data.cards;
tarotStore.usePaidDraw(count);
isSelectionScreen.value = false;
} else {
error.value = response.data.message || 'An error occurred while getting cards.';
}
} catch (err) {
console.error(err);
error.value = 'Failed to get cards from the server. Please contact support.';
} finally {
loading.value = false;
}
} else {
alert("You don't have enough paid draws. Please proceed with payment.");
loading.value = false;
}
} catch (error) {
console.error(error);
} finally {
loading.value = false;
}
};
</script>
<template>
<LandingLayout>
<section v-if="isSelectionScreen" class="py-20 sm:py-24">
<h2 class="mb-16 text-center text-4xl font-bold text-[var(--midnight-blue)] md:text-5xl">Explorez Nos Lectures</h2>
<div class="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
<div
class="flex flex-col gap-6 rounded-2xl border border-[var(--linen)] bg-[var(--pure-white)] p-8 shadow-lg transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl"
>
<div class="text-center">
<h3 class="text-2xl font-bold text-[var(--midnight-blue)]">Lecture Gratuite</h3>
<p class="mt-2 text-5xl font-bold text-[var(--subtle-gold)]">Gratuit</p>
</div>
<button
class="mt-4 flex h-12 w-full items-center justify-center rounded-full bg-[var(--linen)] px-8 font-bold tracking-wide text-[var(--midnight-blue)] transition-all duration-300 hover:bg-[var(--spiritual-earth)] hover:text-[var(--pure-white)]"
@click="handleSelection(1)"
>
Commencer
</button>
</div>
<div
class="flex scale-105 flex-col gap-6 rounded-2xl bg-[var(--midnight-blue)] p-8 shadow-lg ring-2 ring-[var(--subtle-gold)] transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl"
>
<div class="text-center">
<h3 class="text-2xl font-bold text-[var(--pure-white)]">Profilage</h3>
<p class="mt-2 text-5xl font-bold text-[var(--subtle-gold)]">29</p>
</div>
<button
class="mt-4 flex h-12 w-full items-center justify-center rounded-full bg-[var(--subtle-gold)] px-8 font-bold tracking-wide text-[var(--midnight-blue)] transition-all duration-300 hover:bg-[var(--pure-white)]"
@click="handleSelection(3)"
>
Découvrir
</button>
</div>
<div
class="flex flex-col gap-6 rounded-2xl border border-[var(--linen)] bg-[var(--pure-white)] p-8 shadow-lg transition-all duration-300 hover:-translate-y-2 hover:shadow-2xl"
>
<div class="text-center">
<h3 class="text-2xl font-bold text-[var(--midnight-blue)]">Quadrige Doré</h3>
<p class="mt-2 text-5xl font-bold text-[var(--subtle-gold)]">99</p>
</div>
<button
class="mt-4 flex h-12 w-full items-center justify-center rounded-full bg-[var(--linen)] px-8 font-bold tracking-wide text-[var(--midnight-blue)] transition-all duration-300 hover:bg-[var(--spiritual-earth)] hover:text-[var(--pure-white)]"
@click="handleSelection(4)"
>
Explorer
</button>
</div>
</div>
</section>
<ShuffleCardPresentation v-else ref="cardComponent" @draw-card="getCard" />
<card-selection @selectDraw="handleSelection" v-if="isSelectionScreen && !clientSessionId" />
<ShuffleCardPresentation v-else ref="cardComponent" :drawn-cards="cards" />
</LandingLayout>
</template>

View File

@ -0,0 +1,46 @@
<template>
<LandingLayout>
<main class="flex flex-grow items-center justify-center">
<div class="w-full max-w-2xl px-4 py-20 sm:px-6 lg:px-8">
<div class="flex flex-col items-center rounded-2xl border border-[var(--linen)] bg-[var(--pure-white)] p-12 text-center shadow-xl">
<div class="mb-8 flex h-20 w-20 items-center justify-center rounded-full bg-[var(--soft-coral)] shadow-lg">
<svg
class="h-10 w-10 text-[var(--midnight-blue)]"
fill="currentColor"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z"
></path>
</svg>
</div>
<h1 class="text-4xl font-bold text-[var(--midnight-blue)] md:text-5xl">Paiement Échoué</h1>
<p class="mx-auto mt-4 max-w-md text-lg text-[var(--midnight-blue)]/80">
Une erreur est survenue lors du traitement de votre paiement, ou vous avez annulé la transaction.
</p>
<div class="mt-10 w-full border-t border-[var(--linen)]"></div>
<p class="mt-10 text-lg font-medium text-[var(--midnight-blue)]/90">
Veuillez réessayer ou contactez le support si le problème persiste.
</p>
<button
@click="tryAgain"
class="mt-8 flex h-12 max-w-[480px] min-w-[200px] cursor-pointer items-center justify-center overflow-hidden rounded-full bg-[var(--midnight-blue)] px-8 text-base font-bold tracking-wide text-[var(--pure-white)] transition-all duration-300 hover:bg-[var(--spiritual-earth)] hover:shadow-[var(--spiritual-earth)]/30 hover:shadow-lg"
>
<span class="truncate">Réessayer</span>
</button>
</div>
</div>
</main>
</LandingLayout>
</template>
<script setup lang="ts">
import LandingLayout from '@/layouts/app/LandingLayout.vue';
import { router } from '@inertiajs/vue3';
const tryAgain = () => {
// Redirect back to the card selection page
router.visit('/tirage');
};
</script>

View File

@ -0,0 +1,82 @@
<template>
<LandingLayout>
<main class="flex flex-grow items-center justify-center">
<div class="w-full max-w-2xl px-4 py-20 sm:px-6 lg:px-8">
<div class="flex flex-col items-center rounded-2xl border border-[var(--linen)] bg-[var(--pure-white)] p-12 text-center shadow-xl">
<div class="mb-8 flex h-20 w-20 items-center justify-center rounded-full bg-[var(--subtle-gold)] shadow-lg">
<svg
class="h-10 w-10 text-[var(--midnight-blue)]"
fill="currentColor"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z"
></path>
</svg>
</div>
<h1 class="text-4xl font-bold text-[var(--midnight-blue)] md:text-5xl">Paiement Réussi</h1>
<p class="mx-auto mt-4 max-w-md text-lg text-[var(--midnight-blue)]/80">
Votre transaction a été complétée avec succès. L'univers vous attend.
</p>
<div class="mt-10 w-full border-t border-[var(--linen)]"></div>
<p v-if="loading" class="mt-10 text-lg font-medium text-[var(--midnight-blue)]/90">Vérification de votre paiement...</p>
<p v-else class="mt-10 text-lg font-medium text-[var(--midnight-blue)]/90">
Vous pouvez maintenant procéder à votre tirage de cartes.
</p>
<button
@click="proceedToDraw"
:disabled="loading"
class="mt-8 flex h-12 max-w-[480px] min-w-[200px] cursor-pointer items-center justify-center overflow-hidden rounded-full bg-[var(--midnight-blue)] px-8 text-base font-bold tracking-wide text-[var(--pure-white)] transition-all duration-300 hover:bg-[var(--spiritual-earth)] hover:shadow-[var(--spiritual-earth)]/30 hover:shadow-lg disabled:cursor-not-allowed disabled:bg-gray-400 disabled:hover:shadow-none"
>
<span class="truncate">Tirer les cartes</span>
</button>
</div>
</div>
</main>
</LandingLayout>
</template>
<script setup lang="ts">
import LandingLayout from '@/layouts/app/LandingLayout.vue';
import { useTarotStore } from '@/stores/tarot';
import { router } from '@inertiajs/vue3';
import axios from 'axios';
import { onMounted, ref } from 'vue';
const tarotStore = useTarotStore();
const loading = ref(true);
const params = new URLSearchParams(window.location.search);
const clientSessionId = params.get('client_session_id');
onMounted(async () => {
console.log(clientSessionId);
if (clientSessionId) {
try {
const response = await axios.get(`/api/validate-payment?client_session_id=${clientSessionId}`);
if (response.data.success) {
// Mettez à jour le store avec les tirages payés
tarotStore.addPaidDraws(response.data.drawCount);
} else {
// Gérer le cas où le paiement n'est pas validé
alert('Paiement non validé. Veuillez réessayer.');
router.visit('/cancel');
}
} catch (error) {
console.error('Erreur lors de la validation du paiement:', error);
alert('Erreur de validation. Veuillez réessayer.');
router.visit('/cancel');
} finally {
loading.value = false;
}
} else {
// Rediriger si l'ID de session est manquant
// router.visit('/cancel');
}
});
const proceedToDraw = () => {
// Redirige vers la page principale
router.visit(`/tirage?client_session_id=${clientSessionId}`);
};
</script>

View File

@ -4,36 +4,37 @@ import { ref } from 'vue';
export const useTarotStore = defineStore('tarot', () => {
// State
const freeDrawsRemaining = ref(1);
const paidDrawsRemaining = ref(0);
// Actions
function useFreeDraw() {
if (freeDrawsRemaining.value > 0) {
freeDrawsRemaining.value--;
return true; // Indicates a free draw was used
return true;
}
return false; // No more free draws
return false;
}
// You would integrate Stripe here in a more advanced application
// This is a placeholder for your payment logic.
function unlockNewDraws() {
// You would typically call a backend endpoint here to create a Stripe Checkout Session
// and redirect the user. For this example, we'll simulate a successful payment.
console.log('Redirecting to Stripe for payment...');
return new Promise((resolve) => {
setTimeout(() => {
console.log('Payment successful! Adding 1 new draw.');
// After successful payment from Stripe, you would update the state.
// This state update would likely come from a backend webhook.
freeDrawsRemaining.value++;
resolve(true);
}, 2000); // Simulate a network delay
});
// Modified usePaidDraw to handle the correct number of cards and reset the state
function usePaidDraw(count: number) {
if (paidDrawsRemaining.value >= count) {
// Since the draws are 'used', we set the remaining to 0.
// This assumes a user pays for a set number of cards in one go.
paidDrawsRemaining.value = 0;
return true;
}
return false;
}
function addPaidDraws(count: number) {
paidDrawsRemaining.value += count;
}
return {
freeDrawsRemaining,
paidDrawsRemaining,
useFreeDraw,
unlockNewDraws,
usePaidDraw,
addPaidDraws,
};
});

12
routes/api.php Normal file
View File

@ -0,0 +1,12 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:sanctum');
Route::get('/validate-payment', [App\Http\Controllers\StripeController::class, 'validatePayment']);
Route::get('/get-cards', [App\Http\Controllers\StripeController::class, 'getCards']);

View File

@ -2,6 +2,8 @@
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
use App\Models\Payment;
use Illuminate\Http\Request;
Route::get('/', function () {
return Inertia::render('Landing');
@ -15,5 +17,30 @@ Route::get('dashboard', function () {
Route::get('/tirage',[App\Http\Controllers\CardController::class, 'index'])->name('cards.shuffle');
Route::post('/draw-card', [App\Http\Controllers\CardController::class, 'drawCard']);
Route::post('/create-checkout-session', [App\Http\Controllers\StripeController::class, 'createCheckoutSession']);
Route::post('/stripe/webhook', [App\Http\Controllers\WebhookController::class, 'handleWebhook']);
Route::get('/success', function (Request $request) {
$clientSessionId = $request->query('client_session_id');
$payment = Payment::where('client_session_id', $clientSessionId)
->where('status', 'succeeded') // Only check for succeeded payments
->first();
if ($payment) {
return Inertia::render('payments/Success', [
'paymentSuccess' => true,
'drawCount' => $payment->draw_count
]);
}
return Inertia::render('payments/Error', ['message' => 'Payment validation failed.']);
})->name('payment.success');
Route::get('/cancel', function () {
return Inertia::render('payments/Error'); // Your error page
})->name('payments.cancel');
require __DIR__.'/settings.php';
require __DIR__.'/auth.php';