Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ SESSION_DOMAIN=null
# since it needs no real DNS.
# AFFILIATE_SUBDOMAIN=affiliate.affilstack.com

# The developer portal (add-on marketplace developers) on its own
# subdomain. Point its DNS at this same application. Leave unset to serve
# it from /developers on the app instead.
# DEVELOPER_SUBDOMAIN=developer.affilstack.com

BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
Expand Down Expand Up @@ -108,6 +113,14 @@ STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=

# --- AffilStack: Paystack and PayPal (optional payment gateways) ---
# PAYSTACK_PUBLIC_KEY=
# PAYSTACK_SECRET_KEY=
# PAYSTACK_USD_TO_NGN_RATE=1600
# PAYPAL_CLIENT_ID=
# PAYPAL_CLIENT_SECRET=
# PAYPAL_WEBHOOK_ID=

# --- AffilStack: Google Places (Phase 2, Google Maps lead-finding) ---
GOOGLE_PLACES_API_KEY=

Expand Down
24 changes: 24 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,27 @@ updates:
github-actions:
patterns:
- "*"

# PHP and JavaScript libraries: security fixes arrive as pull requests,
# which the Tests workflow checks on PHP 8.4 and 8.5 before merging.
- package-ecosystem: "composer"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
composer-patches:
update-types:
- "patch"
- "minor"

- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
groups:
npm-patches:
update-types:
- "patch"
- "minor"
17 changes: 16 additions & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ name: Tests
on:
push:
branches:
- main
- master
- affilstack-app
- '*.x'
pull_request:
schedule:
Expand All @@ -19,7 +21,7 @@ jobs:
strategy:
fail-fast: true
matrix:
php: [8.3, 8.4, 8.5]
php: [8.4, 8.5]

name: PHP ${{ matrix.php }}

Expand All @@ -39,6 +41,19 @@ jobs:
- name: Install Composer dependencies
run: composer install --prefer-dist --no-interaction --no-progress

# Pages load the built CSS/JS (public/build is not committed), so the
# tests need the front end built first.
- name: Setup Node
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: 22
cache: npm

- name: Build front end
run: |
npm ci --no-audit --no-fund
npm run build

- name: Copy environment file
run: cp .env.example .env

Expand Down
11 changes: 11 additions & 0 deletions .hostinger/site.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"domain": "affilstack.com",
"username": "u281901129",
"type": "php",
"layout": "releases",
"releases": "/home/u281901129/domains/affilstack.com/releases/<time>",
"current": "/home/u281901129/domains/affilstack.com/current -> latest release",
"shared": "/home/u281901129/domains/affilstack.com/shared (.env, storage, backups)",
"public_html": "symlink to current/public",
"deploy": "/home/u281901129/domains/affilstack.com/deploy/deploy.sh <package.zip> [report-name]; rollback.sh [report-name]"
}
190 changes: 190 additions & 0 deletions app/Console/Commands/Dev/SeedDemoData.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,23 @@

namespace App\Console\Commands\Dev;

use App\Models\Addon;
use App\Models\AddonInstall;
use App\Models\ApiToken;
use App\Models\CrmCampaign;
use App\Models\CrmCampaignRecipient;
use App\Models\CrmContact;
use App\Models\CrmEmailSend;
use App\Models\CrmList;
use App\Models\CrmReply;
use App\Models\DeveloperProfile;
use App\Models\Earning;
use App\Models\EmailConnection;
use App\Models\Generation;
use App\Models\LinkClick;
use App\Models\LinkedinReplyDraft;
use App\Models\NetworkConnection;
use App\Models\NetworkProgram;
use App\Models\Offer;
use App\Models\Plan;
use App\Models\Referral;
Expand All @@ -22,6 +27,9 @@
use App\Models\TrackedLink;
use App\Models\User;
use App\Models\WebhookEndpoint;
use App\Models\WordpressDelivery;
use App\Models\WordpressSite;
use App\Services\Marketplace\AddonInstaller;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
Expand Down Expand Up @@ -49,6 +57,9 @@ class SeedDemoData extends Command

public const PASSWORD = 'demo-password';

/** The demo add-on developer (feature 7). */
protected const DEVELOPER_EMAIL = 'dev@demo-dev.affilstack.test';

public function handle(): int
{
if (app()->isProduction()) {
Expand Down Expand Up @@ -80,6 +91,9 @@ public function handle(): int
$this->team($ada, $notion, $hostinger);
$this->integrations($ada);
$this->referrals($ada);
$this->networks($ada, $notion, $hostinger);
$this->wordpress($ada);
$this->marketplace($ada);
});

$this->info('Demo customer ready: '.self::EMAIL.' / '.self::PASSWORD);
Expand All @@ -91,6 +105,7 @@ protected function wipe(): void
{
$ids = User::withTrashed()->where('email', self::EMAIL)->pluck('id');
$ids = $ids->merge(User::withTrashed()->whereIn('agency_owner_id', $ids)->pluck('id'))
->merge(User::withTrashed()->where('email', self::DEVELOPER_EMAIL)->pluck('id'))
->merge(User::withTrashed()->where('email', 'like', '%@demo-referral.affilstack.test')->pluck('id'));

if ($ids->isEmpty()) {
Expand All @@ -112,6 +127,12 @@ protected function wipe(): void
CrmList::whereIn('user_id', $ids)->delete();
CrmContact::whereIn('id', $contactIds)->delete();
Earning::whereIn('user_id', $ids)->delete();
AddonInstall::whereIn('user_id', $ids)->delete();
Addon::whereIn('developer_profile_id', DeveloperProfile::whereIn('user_id', $ids)->pluck('id'))->each(fn (Addon $addon) => $addon->forceFill(['live_version_id' => null])->save());
Addon::whereIn('developer_profile_id', DeveloperProfile::whereIn('user_id', $ids)->pluck('id'))->delete();
DeveloperProfile::whereIn('user_id', $ids)->delete();
WordpressSite::whereIn('user_id', $ids)->delete();
NetworkConnection::whereIn('user_id', $ids)->delete();
TrackedLink::whereIn('id', $linkIds)->delete();
LinkedinReplyDraft::whereIn('user_id', $ids)->delete();
Generation::whereIn('user_id', $ids)->delete();
Expand Down Expand Up @@ -804,6 +825,175 @@ protected function referrals(User $ada): void
* @param array<string, mixed> $input
* @param array<string, mixed> $extra
*/
/**
* Feature 5: three connected affiliate networks, their programs, and
* commissions imported from them.
*/
protected function networks(User $ada, Offer $notion, Offer $hostinger): void
{
$connections = [];

foreach ([['impact', 'IR-4471-AdaOkafor', 'Ada Okafor Media', 2], ['partnerstack', 'ps_ada_okafor', 'Ada Okafor', 5], ['awin', '1189234', 'Okafor Reviews', 9]] as [$network, $accountId, $accountName, $hoursAgo]) {
$connections[$network] = NetworkConnection::forceCreate([
'user_id' => $ada->id,
'network' => $network,
'credentials' => ['demo' => true],
'account_id' => $accountId,
'account_name' => $accountName,
'status' => 'connected',
'last_synced_at' => now()->subHours($hoursAgo),
'programs_synced_at' => now()->subHours($hoursAgo),
'last_sync_new' => [3, 2, 1][count($connections)],
'last_sync_updated' => [5, 1, 0][count($connections)],
]);
}

$programs = [
['impact', 'Hostinger', 'Hostinger International', 'joined', 'Web hosting', '60% per sale', 'https://www.hostinger.com'],
['impact', 'Canva', 'Canva Pty Ltd', 'joined', 'Design', '$36 per Pro sign-up', 'https://www.canva.com'],
['impact', 'Semrush', 'Semrush Inc.', 'available', 'Marketing software', '$200 per sale', 'https://www.semrush.com'],
['impact', 'Shopify', 'Shopify Inc.', 'pending', 'E-commerce', '$150 per paid store', 'https://www.shopify.com'],
['partnerstack', 'Notion', 'Notion Labs', 'joined', 'Productivity', '50% for 12 months', 'https://www.notion.so'],
['partnerstack', 'Monday.com', 'monday.com', 'joined', 'Project management', '100% first year', 'https://monday.com'],
['partnerstack', 'Webflow', 'Webflow Inc.', 'available', 'Website builder', '50% for 12 months', 'https://webflow.com'],
['awin', 'Jumia', 'Jumia Nigeria', 'joined', 'Retail', '6–11% per order', 'https://www.jumia.com.ng'],
['awin', 'Etsy', 'Etsy UK', 'available', 'Retail', '4% per order', 'https://www.etsy.com'],
];

foreach ($programs as $i => [$network, $name, $advertiser, $status, $category, $commission, $homepage]) {
NetworkProgram::forceCreate([
'network_connection_id' => $connections[$network]->id,
'external_id' => (string) (10000 + $i),
'name' => $name,
'advertiser_name' => $advertiser,
'status' => $status,
'homepage_url' => $homepage,
'tracking_url' => $status === 'joined' ? $homepage.'/?ref=ada-'.$network : null,
'category' => $category,
'currency' => 'USD',
'commission' => $commission,
'description' => $name.' affiliate program on '.ucfirst($network).'.',
'deep_linking' => true,
'last_seen_at' => now()->subHours(3),
]);
}

Earning::query()->where('user_id', $ada->id)->where('network', '!=', 'manual')->update(['source' => 'network_sync']);
}

/**
* Feature 4: a connected WordPress blog with articles sent as drafts.
*/
protected function wordpress(User $ada): void
{
$site = WordpressSite::forceCreate([
'user_id' => $ada->id,
'site_url' => 'https://okaforreviews.com',
'rest_url' => 'https://okaforreviews.com/wp-json/',
'name' => 'Okafor Reviews',
'secret' => Str::random(64),
'status' => 'connected',
'auto_send' => true,
'seo_plugin' => 'rank_math',
'wp_version' => '6.8.2',
'plugin_version' => '1.0.0',
'connected_at' => now()->subDays(9),
'last_seen_at' => now()->subMinutes(40),
]);

Generation::query()->where('user_id', $ada->id)->where('module', 'blog_article')->get()->each(function (Generation $generation, int $i) use ($site, $ada) {
WordpressDelivery::forceCreate([
'wordpress_site_id' => $site->id,
'generation_id' => $generation->id,
'requested_by_id' => $ada->id,
'status' => 'sent',
'wp_post_id' => 412 + $i,
'wp_post_status' => $i === 0 ? 'publish' : 'draft',
'edit_url' => 'https://okaforreviews.com/wp-admin/post.php?post='.(412 + $i).'&action=edit',
'preview_url' => 'https://okaforreviews.com/?p='.(412 + $i).'&preview=true',
'automatic' => $i > 0,
'sent_at' => now()->subDays(4 - $i),
]);
});
}

/**
* Feature 7: a developer with three approved add-ons; Ada has added
* the template pack and the automation recipe.
*/
protected function marketplace(User $ada): void
{
$developer = User::forceCreate([
'name' => 'Tunde Bakare',
'email' => self::DEVELOPER_EMAIL,
'password' => Hash::make(Str::random(32)),
'email_verified_at' => now(),
'credits_balance' => 0,
]);

$profile = DeveloperProfile::forceCreate([
'user_id' => $developer->id,
'display_name' => 'Northwind Labs',
'slug' => 'northwind-labs',
'support_email' => 'help@northwind.example',
'status' => 'active',
'terms_accepted_at' => now()->subDays(20),
]);

$listings = [
['app', 'rank-watcher', 'Rank Watcher', 'See where your review articles rank on Google, every morning.', 'analytics', 1500, 48, [
'install_url' => 'https://rankwatcher.example/connect', 'privacy_url' => 'https://rankwatcher.example/privacy',
'redirect_uris' => ['https://rankwatcher.example/callback'], 'scopes' => ['profile:read', 'offers:read', 'content:read'],
]],
['template_pack', 'hook-vault', 'Hook Vault', '120 scroll-stopping hooks for TikTok, Reels and X.', 'content', 900, 131, ['content' => ['items' => [
['kind' => 'hook', 'title' => 'Curiosity hook', 'body' => 'Nobody tells you this about [PRODUCT]…'],
['kind' => 'hook', 'title' => 'Before and after', 'body' => 'I spent [TIME] doing [TASK] by hand. Then I found [PRODUCT].'],
['kind' => 'email', 'title' => 'Cold email: first touch', 'body' => "Hi [NAME],\n\nI noticed you're comparing tools for [PROBLEM]…"],
['kind' => 'post', 'title' => 'LinkedIn: lesson post', 'body' => 'The one thing I wish I knew before recommending [PRODUCT]:'],
]]]],
['automation', 'typeform-leads-to-crm', 'Typeform Leads to CRM', 'Add every new Typeform lead to your AffilStack CRM automatically.', 'automation', 0, 77, ['content' => [
'platform' => 'zapier', 'share_url' => 'https://zapier.com/shared/affilstack-typeform',
'steps' => "- Open the Zap template\n- Connect Typeform\n- Paste your AffilStack API key\n- Turn it on",
]]],
];

foreach ($listings as [$type, $slug, $name, $tagline, $category, $price, $installs, $extra]) {
$addon = Addon::forceCreate([
'developer_profile_id' => $profile->id,
'slug' => $slug,
'type' => $type,
'status' => 'published',
'installs_count' => $installs,
'is_featured' => $slug === 'hook-vault',
'published_at' => now()->subDays(14),
]);

$version = $addon->versions()->create(array_merge([
'version' => '1.0.0',
'status' => 'approved',
'name' => $name,
'tagline' => $tagline,
'description' => $tagline."\n\nBuilt by an affiliate marketer for affiliate marketers.\n\n- Works on every AffilStack plan\n- Set up in under five minutes\n- Cancel any time",
'category' => $category,
'pricing' => $price > 0 ? 'monthly' : 'free',
'price_cents' => $price,
'reviewed_at' => now()->subDays(14),
], $extra));

$addon->forceFill(['live_version_id' => $version->id])->save();

if ($type === 'app') {
$addon->issueClientCredentials();
}

if ($type !== 'app') {
$install = app(AddonInstaller::class)->install($ada->fresh(), $addon->fresh(), [], 'active');
$install->update(['status' => 'active', 'price_cents' => $price, 'current_period_end' => $price > 0 ? now()->addDays(21) : null]);
app(AddonInstaller::class)->provision($install->fresh());
}
}
}

protected function generation(User $ada, Offer $offer, string $module, string $output, array $meta, int $daysAgo, array $input = [], ?string $calendarStatus = null, ?int $scheduledInDays = null, array $extra = []): Generation
{
$created = now()->subDays($daysAgo)->subHours(random_int(1, 8));
Expand Down
Loading
Loading