diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6d0709..160d817 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -12,7 +12,7 @@ jobs: release: uses: saucebase-dev/saucebase/.github/workflows/semantic-release.yml@main with: - version_file: 'module.json' + version_file: 'composer.json' version_file_path: '.version' prerelease: false secrets: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e338f2f..1f2ba75 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,5 +13,5 @@ jobs: test: uses: saucebase-dev/saucebase/.github/workflows/test-module.yml@main with: - module: Demo - dependencies: saucebase/auth saucebase/settings saucebase/billing saucebase/announcements saucebase/blog saucebase/roadmap saucebase/themes + module: demo + dependencies: saucebase/auth saucebase/billing saucebase/blog saucebase/roadmap diff --git a/LICENSE b/LICENSE index f9a509b..cf78a69 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2025 Saucebase +Copyright (c) 2026 Saucebase Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Taskfile.yml b/Taskfile.yml index 692eeee..a9e5ca8 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -9,11 +9,17 @@ tasks: test:e2e: desc: Run E2E tests for Demo module - cmd: npx playwright test --project="@Demo*" {{.CLI_ARGS}} + cmd: npx playwright test --project="@demo*" {{.CLI_ARGS}} interactive: true + # ── Database ────────────────────────────────────────────────── + + db:seed: + desc: Seed the Demo module database + cmd: php artisan modules:seed --module=demo + # ── Code Generation ──────────────────────────────────────────── types:generate: desc: Generate TypeScript types from PHP DTOs and enums - cmd: php artisan module:generate-types Demo + cmd: php artisan module:generate-types demo diff --git a/app/Http/Controllers/DemoController.php b/app/Http/Controllers/DemoController.php deleted file mode 100644 index 3f10d73..0000000 --- a/app/Http/Controllers/DemoController.php +++ /dev/null @@ -1,35 +0,0 @@ -isEnabled('Billing')) { - $data['products'] = Product::displayable()->get(); - } - - if (app('modules')->isEnabled('Blog')) { - $posts = Post::published() - ->with(['category', 'author']) - ->orderByDesc('published_at') - ->limit(3) - ->get(); - - $data['latestPosts'] = $posts->map(fn (Post $post) => PostData::fromPost($post)); - } - - return Inertia::render('Demo::Index', $data)->withSSR(); - } -} diff --git a/app/Providers/DemoServiceProvider.php b/app/Providers/DemoServiceProvider.php deleted file mode 100644 index 34351ca..0000000 --- a/app/Providers/DemoServiceProvider.php +++ /dev/null @@ -1,16 +0,0 @@ -group(module_path('Demo', '/routes/web.php')); - Route::middleware('api') - ->group(module_path('Demo', '/routes/api.php')); - } -} diff --git a/composer.json b/composer.json index 6fd111f..ff44ac2 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,9 @@ { "name": "saucebase/demo", - "description": "", + "description": "Demo module", "type": "saucebase-module", + "license": "proprietary", + "version": "0.5.0", "authors": [ { "name": "Saucebase", @@ -10,13 +12,15 @@ ], "extra": { "laravel": { - "providers": [], + "providers": [ + "Modules\\Demo\\Providers\\DemoServiceProvider" + ], "aliases": {} } }, "autoload": { "psr-4": { - "Modules\\Demo\\": "app/", + "Modules\\Demo\\": "src/", "Modules\\Demo\\Database\\Factories\\": "database/factories/", "Modules\\Demo\\Database\\Seeders\\": "database/seeders/" } @@ -25,5 +29,6 @@ "psr-4": { "Modules\\Demo\\Tests\\": "tests/" } - } + }, + "minimum-stability": "stable" } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..e8265a6 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,13 @@ +call(DemoDatabaseSeeder::class); + } +} diff --git a/database/seeders/DemoDatabaseSeeder.php b/database/seeders/DemoDatabaseSeeder.php new file mode 100644 index 0000000..1731f91 --- /dev/null +++ b/database/seeders/DemoDatabaseSeeder.php @@ -0,0 +1,193 @@ +call([ + BillingDatabaseSeeder::class, + RoadmapDatabaseSeeder::class, + ]); + + $proMonthly = Price::where('provider_price_id', 'price_1SyadREx2sHJcHgwCt0ReZEJ')->firstOrFail(); + $proYearly = Price::where('provider_price_id', 'price_1SyaCQEx2sHJcHgwEC9VmwSZ')->firstOrFail(); + $teamMonthly = Price::where('provider_price_id', 'price_1SyaL0Ex2sHJcHgwWaaTGLgo')->firstOrFail(); + $teamYearly = Price::where('provider_price_id', 'price_1SyaLWEx2sHJcHgw3fQdYV0J')->firstOrFail(); + + $users = User::factory()->count(30)->create(); + foreach ($users as $user) { + $user->assignRole(Role::USER->value); + } + + $customers = collect(); + $paymentMethods = collect(); + foreach ($users->take(25) as $user) { + $customer = Customer::factory()->withAddress()->create(['user_id' => $user->id]); + $paymentMethod = PaymentMethod::factory()->default()->visa()->create(['customer_id' => $customer->id]); + $customers->push($customer); + $paymentMethods->push($paymentMethod); + } + + // 12 month-start dates, oldest first, for backdating + $months = collect(range(11, 0))->map(fn ($i) => now()->subMonths($i)->startOfMonth()); + + // [customerIndex, price, state] + $assignments = [ + [0, $proMonthly, 'active'], + [1, $proMonthly, 'active'], + [2, $proMonthly, 'active'], + [3, $proMonthly, 'active'], + [4, $proMonthly, 'active'], + [5, $proMonthly, 'active'], + [6, $proMonthly, 'active'], + [7, $proMonthly, 'active'], + [8, $proMonthly, 'active'], + [9, $proMonthly, 'active'], + [10, $proMonthly, 'active'], + [11, $proMonthly, 'active'], + [12, $proYearly, 'active'], + [13, $proYearly, 'active'], + [14, $proYearly, 'active'], + [15, $proYearly, 'active'], + [16, $proYearly, 'active'], + [17, $teamMonthly, 'active'], + [18, $teamMonthly, 'active'], + [19, $teamMonthly, 'active'], + [20, $teamMonthly, 'active'], + [21, $teamYearly, 'active'], + [22, $teamYearly, 'active'], + [23, $proMonthly, 'onTrial'], + [24, $proMonthly, 'onTrial'], + // Cancelled prior subscriptions reusing the same customers + [0, $proMonthly, 'cancelled'], + [1, $teamMonthly, 'cancelled'], + [2, $proYearly, 'cancelled'], + // Past due + [3, $teamMonthly, 'pastDue'], + [4, $proMonthly, 'pastDue'], + ]; + + $activeSubscriptions = collect(); + $monthIndex = 0; + + foreach ($assignments as [$customerIndex, $price, $state]) { + $customer = $customers->get($customerIndex); + $paymentMethod = $paymentMethods->get($customerIndex); + $createdAt = $months[$monthIndex % 12]; + $monthIndex++; + + $factory = Subscription::factory()->state([ + 'customer_id' => $customer->id, + 'price_id' => $price->id, + 'payment_method_id' => $paymentMethod->id, + ]); + + $factory = match ($state) { + 'cancelled' => $factory->cancelled(), + 'pastDue' => $factory->pastDue(), + 'onTrial' => $factory->onTrial(), + default => $factory, + }; + + $subscription = $factory->create(['created_at' => $createdAt]); + + if (in_array($state, ['active', 'onTrial', 'pastDue'])) { + $customer->user->assignRole(Role::SUBSCRIBER->value); + } + + if ($state === 'active') { + $activeSubscriptions->push($subscription); + } + } + + // Payments: one per month per active subscription since its creation. + // This produces a naturally growing monthly revenue trend. + $activeSubscriptions = Subscription::whereIn('id', $activeSubscriptions->pluck('id'))->with('price')->get(); + + foreach ($activeSubscriptions as $subscription) { + $cursor = Carbon::parse($subscription->created_at)->startOfMonth(); + $nowMonth = now()->startOfMonth(); + + while ($cursor <= $nowMonth) { + $succeeded = fake()->boolean(90); + + $paymentFactory = Payment::factory()->state([ + 'customer_id' => $subscription->customer_id, + 'subscription_id' => $subscription->id, + 'price_id' => $subscription->price_id, + 'amount' => $subscription->price->amount, + ]); + + if (! $succeeded) { + $paymentFactory = $paymentFactory->failed(); + } + + $payment = $paymentFactory->create(['created_at' => $cursor->copy()]); + + if ($succeeded) { + Invoice::factory()->create([ + 'customer_id' => $subscription->customer_id, + 'subscription_id' => $subscription->id, + 'payment_id' => $payment->id, + 'subtotal' => $subscription->price->amount, + 'total' => $subscription->price->amount, + 'paid_at' => $cursor->copy(), + 'created_at' => $cursor->copy(), + ]); + } + + $cursor->addMonth(); + } + } + + // Checkout sessions spread across 12 months for the conversion chart. + // ~2-3 completed, 1 abandoned, 0-1 expired per month → ~67% conversion rate. + foreach ($months as $month) { + CheckoutSession::factory() + ->completed() + ->count(fake()->numberBetween(2, 3)) + ->create(['customer_id' => null, 'price_id' => $proMonthly->id, 'created_at' => $month]); + + CheckoutSession::factory() + ->abandoned() + ->create(['customer_id' => null, 'price_id' => $proMonthly->id, 'created_at' => $month]); + + if (fake()->boolean(50)) { + CheckoutSession::factory() + ->expired() + ->create(['customer_id' => null, 'price_id' => $proMonthly->id, 'created_at' => $month]); + } + } + + // Roadmap votes: each demo user votes on 3-6 random items. + $roadmapItems = RoadmapItem::all(); + foreach ($users as $user) { + $itemsToVote = $roadmapItems->random(fake()->numberBetween(3, 6)); + foreach ($itemsToVote as $item) { + RoadmapVote::firstOrCreate( + ['roadmap_item_id' => $item->id, 'user_id' => $user->id], + ['type' => fake()->randomElement([VoteType::Up, VoteType::Down])] + ); + } + } + } +} diff --git a/module.json b/module.json deleted file mode 100644 index e355c4f..0000000 --- a/module.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "Demo", - "alias": "demo", - "description": "Demo module", - "author": "Saucebase", - "version": "v0.5.0", - "keywords": [ - "demo" - ], - "priority": 0, - "providers": [ - "Modules\\Demo\\Providers\\DemoServiceProvider" - ], - "files": [] -} diff --git a/resources/assets/images/dashboard-admin-dark.png b/resources/assets/images/dashboard-admin-dark.png new file mode 100644 index 0000000..5443273 Binary files /dev/null and b/resources/assets/images/dashboard-admin-dark.png differ diff --git a/resources/assets/images/dashboard-admin-light.png b/resources/assets/images/dashboard-admin-light.png new file mode 100644 index 0000000..dfc7d6f Binary files /dev/null and b/resources/assets/images/dashboard-admin-light.png differ diff --git a/resources/js/app.ts b/resources/js/app.ts index e9ea054..3043f15 100644 --- a/resources/js/app.ts +++ b/resources/js/app.ts @@ -1,23 +1 @@ -// import { registerIcon } from '@/lib/navigation'; -// import IconExample from '~icons/lucide/example'; - -import '../css/style.css'; - -/** - * Demo module setup - * Called during app initialization before mounting - */ -export function setup() { - console.debug('Demo module loaded'); - - // Register icons for navigation items defined in routes/navigation.php - // registerIcon('demo', IconExample); -} - -/** - * Demo module after mount logic - * Called after the app has been mounted - */ -export function afterMount() { - console.debug('Demo module after mount logic executed'); -} +export * from './vue/app'; diff --git a/resources/js/pages/sections/FeaturesSection.vue b/resources/js/pages/sections/FeaturesSection.vue deleted file mode 100644 index 49eb72c..0000000 --- a/resources/js/pages/sections/FeaturesSection.vue +++ /dev/null @@ -1,178 +0,0 @@ - - - diff --git a/resources/js/pages/sections/HeroSection.vue b/resources/js/pages/sections/HeroSection.vue deleted file mode 100644 index a53f974..0000000 --- a/resources/js/pages/sections/HeroSection.vue +++ /dev/null @@ -1,161 +0,0 @@ - - - diff --git a/resources/js/types/page-props.d.ts b/resources/js/types/page-props.d.ts index acfca86..01e84f4 100644 --- a/resources/js/types/page-props.d.ts +++ b/resources/js/types/page-props.d.ts @@ -2,7 +2,7 @@ // Augmentations here are auto-picked up when the module is enabled (no core edits needed). // See docs: architecture/module-system#typescript-page-props -// declare module '@inertiajs/vue3' { +// declare module '@inertiajs/core' { // interface PageProps { // // my_shared_prop?: MyType; // } diff --git a/resources/js/vue/app.ts b/resources/js/vue/app.ts new file mode 100644 index 0000000..17a4e1c --- /dev/null +++ b/resources/js/vue/app.ts @@ -0,0 +1,23 @@ +// import { registerIcon } from '@/lib/navigation'; +// import IconExample from '~icons/lucide/example'; + +import '@modules/demo/resources/css/style.css'; + +/** + * Demo module setup + * Called during app initialization before mounting + */ +export function setup() { + console.debug('Demo module loaded'); + + // Register icons for navigation items defined in routes/navigation.php + // registerIcon('demo', IconExample); +} + +/** + * Demo module after mount logic + * Called after the app has been mounted + */ +export function afterMount() { + console.debug('Demo module after mount logic executed'); +} diff --git a/resources/js/pages/Index.vue b/resources/js/vue/pages/Index.vue similarity index 90% rename from resources/js/pages/Index.vue rename to resources/js/vue/pages/Index.vue index a7b80cd..c7500bd 100644 --- a/resources/js/pages/Index.vue +++ b/resources/js/vue/pages/Index.vue @@ -7,11 +7,11 @@ import FilamentSection from './sections/FilamentSection.vue'; import { Head } from '@inertiajs/vue3'; -import ProductSection from '@modules/Billing/resources/js/components/ProductSection.vue'; -import type { Product } from '@modules/Billing/resources/js/types'; -import LatestPostsSection from '@modules/Blog/resources/js/components/LatestPostsSection.vue'; +import ProductSection from '@modules/billing/resources/js/vue/components/ProductSection.vue'; +import type { Product } from '@modules/billing/resources/js/types'; +import LatestPostsSection from '@modules/blog/resources/js/vue/components/LatestPostsSection.vue'; import HeroSection from './sections/HeroSection.vue'; -import Testimonial from './sections/Testimonial.vue'; +import ModulesSection from './sections/ModulesSection.vue'; defineProps<{ products?: Product[]; @@ -36,9 +36,9 @@ defineProps<{
+ -

+import { trans } from 'laravel-vue-i18n'; +import { + BookOpen, + Braces, + Component, + FlaskConical, + Languages, + LayoutDashboard, + Paintbrush, + Terminal, +} from 'lucide-vue-next'; + +const features = [ + { + name: () => trans('One-command setup'), + description: () => trans("One command. You're running."), + icon: Terminal, + }, + { + name: () => trans('Full TypeScript'), + description: () => trans('PHP types generate TypeScript automatically.'), + icon: Braces, + }, + { + name: () => trans('Theming system'), + description: () => trans('15 themes and a live editor.'), + icon: Paintbrush, + }, + { + name: () => trans('Filament admin'), + description: () => trans('Admin panel ready on day one.'), + icon: LayoutDashboard, + }, + { + name: () => trans('Testing suite'), + description: () => trans('PHPUnit and Playwright E2E, pre-configured per module.'), + icon: FlaskConical, + }, + { + name: () => trans('Multi-language'), + description: () => trans('From day one.'), + icon: Languages, + }, + { + name: () => trans('shadcn UI'), + description: () => trans('Beautiful components with dark mode built in.'), + icon: Component, + }, + { + name: () => trans('Complete documentation'), + description: () => trans('Module guides, architecture docs, and AI context files for every module.'), + icon: BookOpen, + }, +]; + + + diff --git a/resources/js/pages/sections/FilamentSection.vue b/resources/js/vue/pages/sections/FilamentSection.vue similarity index 95% rename from resources/js/pages/sections/FilamentSection.vue rename to resources/js/vue/pages/sections/FilamentSection.vue index b510be7..a29e7c4 100644 --- a/resources/js/pages/sections/FilamentSection.vue +++ b/resources/js/vue/pages/sections/FilamentSection.vue @@ -75,7 +75,7 @@ const features = [
diff --git a/resources/js/vue/pages/sections/HeroSection.vue b/resources/js/vue/pages/sections/HeroSection.vue new file mode 100644 index 0000000..1831b9c --- /dev/null +++ b/resources/js/vue/pages/sections/HeroSection.vue @@ -0,0 +1,146 @@ + + + diff --git a/resources/js/vue/pages/sections/ModulesSection.vue b/resources/js/vue/pages/sections/ModulesSection.vue new file mode 100644 index 0000000..3524825 --- /dev/null +++ b/resources/js/vue/pages/sections/ModulesSection.vue @@ -0,0 +1,194 @@ + + + + + diff --git a/resources/js/pages/sections/TechStackSection.vue b/resources/js/vue/pages/sections/TechStackSection.vue similarity index 100% rename from resources/js/pages/sections/TechStackSection.vue rename to resources/js/vue/pages/sections/TechStackSection.vue diff --git a/resources/js/pages/sections/Testimonial.vue b/resources/js/vue/pages/sections/Testimonial.vue similarity index 100% rename from resources/js/pages/sections/Testimonial.vue rename to resources/js/vue/pages/sections/Testimonial.vue diff --git a/routes/api.php b/routes/api.php deleted file mode 100644 index c3115d1..0000000 --- a/routes/api.php +++ /dev/null @@ -1,8 +0,0 @@ -prefix('api/v1/demo')->group(function () { -// Route::apiResource('demo', DemoController::class, ['as' => 'api']); -// }); diff --git a/routes/navigation.php b/routes/navigation.php index 73cde33..d29e9de 100644 --- a/routes/navigation.php +++ b/routes/navigation.php @@ -14,33 +14,15 @@ */ // Landing Page Navigation -Navigation::add('Pricing', '/#pricing', function (Section $section) { +Navigation::add('Stripe Integration', '/#pricing', function (Section $section) { $section->attributes([ 'group' => 'landing', - 'slug' => 'pricing', + 'slug' => 'stripe-integration', 'external' => true, 'order' => 1, ]); }); -Navigation::add('Features', '/#features', function (Section $section) { - $section->attributes([ - 'group' => 'landing', - 'slug' => 'features', - 'external' => true, - 'order' => 0, - ]); -}); - -// Navigation::add('FAQ', '/#faq', function (Section $section) { -// $section->attributes([ -// 'group' => 'landing', -// 'slug' => 'faq', -// 'external' => true, -// 'order' => 1, -// ]); -// }); - Navigation::add( 'Docs', 'https://saucebase-dev.github.io/docs/', diff --git a/routes/web.php b/routes/web.php deleted file mode 100644 index 7728046..0000000 --- a/routes/web.php +++ /dev/null @@ -1,6 +0,0 @@ -get(); + + $posts = Post::published() + ->with(['category', 'author']) + ->orderByDesc('published_at') + ->limit(3) + ->get(); + + $data['latestPosts'] = $posts->map(fn (Post $post) => PostData::fromPost($post)); + + return Inertia::render('Demo::Index', $data)->withSSR(); + } +} diff --git a/src/Providers/DemoServiceProvider.php b/src/Providers/DemoServiceProvider.php new file mode 100644 index 0000000..d15c14b --- /dev/null +++ b/src/Providers/DemoServiceProvider.php @@ -0,0 +1,9 @@ +expectNotToPerformAssertions(); + + $this->seed(DatabaseSeeder::class); + } +}