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
37 changes: 37 additions & 0 deletions .github/workflows/deploy-application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,33 @@ jobs:
create-deployment-artifact-shop:
name: Create Deployment Artifact (Shop)
runs-on: ubuntu-latest

# The shop reads the admin-owned database, so tests need that schema.
# We stand up Postgres, build the schema with admin's migrations, then run
# the shop test suite against it.
services:
postgres:
image: postgres:16
env:
POSTGRES_DB: shop_flow_test
POSTGRES_USER: shop_flow
POSTGRES_PASSWORD: password
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U shop_flow"
--health-interval 10s
--health-timeout 5s
--health-retries 5

env:
DB_CONNECTION: pgsql
DB_HOST: 127.0.0.1
DB_PORT: 5432
DB_DATABASE: shop_flow_test
DB_USERNAME: shop_flow
DB_PASSWORD: password

steps:
- uses: actions/checkout@v2

Expand All @@ -61,6 +88,16 @@ jobs:
with:
php-version: 8.5
tools: composer
extensions: pdo_pgsql, pgsql

- name: Build shared schema (admin migrations)
working-directory: ./admin
run: |
composer install --no-interaction --prefer-dist --optimize-autoloader
cp .env.example .env
php artisan key:generate
php artisan migrate --force
php artisan db:seed --class="Database\\Seeders\\SettingSeeder" --force

- name: Install PHP Dependencies
working-directory: ./shop
Expand Down
24 changes: 14 additions & 10 deletions shop/.env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
APP_NAME=Laravel
APP_NAME=ShopFlow
APP_ENV=local
APP_KEY=
APP_DEBUG=true
Expand All @@ -20,24 +20,28 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug

DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
# Shop reads the shared database owned by the admin app.
# Point these at the same Postgres instance/credentials as admin.
DB_CONNECTION=pgsql
DB_HOST=db
DB_PORT=5432
DB_DATABASE=
DB_USERNAME=
DB_PASSWORD=

SESSION_DRIVER=database
# Shop does not own the session/cache/jobs tables in the shared DB,
# so it uses file/sync drivers instead of the database driver.
SESSION_DRIVER=file
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null

BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
QUEUE_CONNECTION=sync

CACHE_STORE=database
CACHE_STORE=file
# CACHE_PREFIX=

MEMCACHED_HOST=127.0.0.1
Expand Down
17 changes: 15 additions & 2 deletions shop/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,9 @@ The shop UI uses **Inertia + Vue 3** (SSR enabled). Clean, readable code is a ha
- Extract repeated logic into composables under `resources/js/composables/` (e.g. `useCart.ts`).
- Style with Tailwind utility classes, RTL-first (see fonts/RTL section). No inline styles, no copy-pasted markup; reuse components.
- **Brand color is `#ff8615`.** It is registered in `resources/css/app.css` as `--color-brand`, so use the Tailwind `brand` utilities (`bg-brand`, `text-brand`, `border-brand`, ...) for primary actions and accents. Do not hardcode the hex in components.
- **Icons: FontAwesome only, for a uniform icon set.** Always render icons through the shared `<Icon>` component (`resources/js/Components/Icon.vue`). Never use raw SVGs, emoji, or another icon library, and do not place `<FontAwesomeIcon>` directly in components.
- Register every icon as an object in `resources/js/fontawesome.js` (solid from `@fortawesome/free-solid-svg-icons`, brands from `@fortawesome/free-brands-svg-icons`) and pass the imported icon object: `<Icon :icon="..." />`.
- Do NOT use string names with `library.add` (e.g. `['fab','instagram']`). Inertia turns FontAwesome's missing-icon `console.error` into an SSR exception, so string lookups break SSR. Passing icon objects is the SSR-safe, tree-shakeable pattern.
- Pages must use Inertia's `<Head>` for SEO tags (see SEO section) and render meaningful content server-side.

## Language, RTL & fonts
Expand Down Expand Up @@ -275,8 +278,18 @@ When adding a new feature, build files in this order, matching existing files:
## Tests

- This project uses **Pest** (not PHPUnit — this overrides the auto-generated boost note above). Write tests as Pest functions (`it(...)`, `test(...)`, `expect(...)`) with `declare(strict_types=1);`. Create them with `php artisan make:test --pest {name}`.
- Global setup lives in `tests/Pest.php`: `Feature` tests use `TestCase` + `RefreshDatabase`.
- Most tests should be feature tests. Use factories (and their custom states) to build data. Assert with `assertDatabaseHas(...)`, `assertModelMissing(...)`, or `expect($model->refresh())`.
- Global setup lives in `tests/Pest.php`: `Feature` tests use `TestCase` + `DatabaseTransactions` (each test runs in a transaction that is rolled back).
- **Shared database, not sqlite.** The shop is a read-only consumer of the admin-owned schema, so tests run against a real Postgres test database (`shop_flow_test`) whose schema is built by **admin's** migrations — never the shop's. The shop must not own or migrate those tables. Do not switch tests back to sqlite/`RefreshDatabase`; that hides schema drift from production.
- **One-time local setup** (run from the admin container, pointing at the test DB):

```bash
createdb -U shop_flow shop_flow_test # or CREATE DATABASE shop_flow_test;
cd admin
DB_DATABASE=shop_flow_test php artisan migrate --force
DB_DATABASE=shop_flow_test php artisan db:seed --class="Database\Seeders\SettingSeeder" --force
```

- Most tests should be feature tests. Build test rows with factories inside the test (they roll back via the transaction). Assert with `assertDatabaseHas(...)`, `assertModelMissing(...)`, or `expect($model->refresh())`.
- Run the minimum tests needed with a filter before finalizing, then `composer test-dev` for the full suite.

## Business constraints
Expand Down
88 changes: 87 additions & 1 deletion shop/app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

namespace App\Http\Middleware;

use App\Models\Setting;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Inertia\Middleware;

class HandleInertiaRequests extends Middleware
Expand Down Expand Up @@ -37,9 +39,93 @@ public function version(Request $request): ?string
*/
public function share(Request $request): array
{
$settings = $this->autoloadedSettings();

return [
...parent::share($request),
//
'seo' => [
'siteName' => (string) config('app.name'),
'url' => $this->canonicalUrl($request),
'locale' => 'fa_IR',
],
'footer' => [
'about' => $this->value($settings, 'footer_about'),
'columns' => [
['title' => 'فروشگاه', 'links' => $this->json($settings, 'footer_links_shop')],
['title' => 'پشتیبانی', 'links' => $this->json($settings, 'footer_links_support')],
],
'contact' => [
'title' => 'ارتباط با ما',
'phone' => $this->value($settings, 'site_phone'),
'email' => $this->value($settings, 'site_email'),
'hours' => $this->json($settings, 'site_working_hours'),
'address' => $this->value($settings, 'site_address'),
],
'socials' => $this->json($settings, 'footer_socials'),
'copyright' => $this->value($settings, 'footer_copyright'),
],
];
}

/**
* Build the canonical/og URL from the configured public origin (APP_URL)
* rather than the raw request, so it stays correct behind TLS-terminating
* proxies regardless of the internal scheme/host.
*/
private function canonicalUrl(Request $request): string
{
$base = rtrim((string) config('app.url'), '/');
$path = $request->path();

return $path === '/' ? $base : $base.'/'.$path;
}

/**
* Load the globally autoloaded settings as a key => content map.
*
* Guarded with rescue() so the storefront still renders in environments
* where the admin-owned settings table is absent (e.g. the test database).
*
* @return Collection<array-key, mixed>
*/
private function autoloadedSettings(): Collection
{
/** @var Collection<array-key, mixed> $settings */
$settings = rescue(
fn (): Collection => Setting::query()->autoloaded()->pluck('content', 'key'),
collect(),
false,
);

return $settings;
}

/**
* @param Collection<array-key, mixed> $settings
*/
private function value(Collection $settings, string $key): ?string
{
$value = $settings->get($key);

return is_string($value) ? $value : null;
}

/**
* Decode a JSON setting value into an array.
*
* @param Collection<array-key, mixed> $settings
* @return array<int, mixed>
*/
private function json(Collection $settings, string $key): array
{
$value = $this->value($settings, $key);

if ($value === null || $value === '') {
return [];
}

$decoded = json_decode($value, true);

return is_array($decoded) ? $decoded : [];
}
}
37 changes: 37 additions & 0 deletions shop/app/Models/Setting.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

declare(strict_types=1);

namespace App\Models;

use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

/**
* @property positive-int $id
* @property string $key
* @property string|null $label
* @property string|null $content
* @property bool $autoload
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
class Setting extends Model
{
protected $fillable = [
'key',
'label',
'content',
'autoload',
];

protected $casts = [
'autoload' => 'boolean',
];

public function scopeAutoloaded(Builder $query): Builder
{
return $query->where('autoload', true);
}
}
8 changes: 8 additions & 0 deletions shop/bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@
$middleware->web(append: [
HandleInertiaRequests::class,
]);

$middleware->trustProxies(
at: '*',
headers: Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_HOST
| Request::HEADER_X_FORWARDED_PORT
| Request::HEADER_X_FORWARDED_PROTO,
);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
Expand Down
1 change: 1 addition & 0 deletions shop/docs/CACHE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Legend: `[ ]` not started, `[x]` implemented.
| 7 | `varieties.product.{product_id}` | All varieties for a product (price, inventory, status) | 15 min | Variety saved / deleted |
| 9 | `pages.{slug}` | Single published page record | 1 hour | Page saved / deleted |
| 10 | `faqs.{position}` | FAQs for a given position (null = main FAQ page) | 1 hour | FAQ saved / deleted |
| 11 | `settings.autoload` | Autoloaded site settings (key => content), used for footer/contact | 1 hour | Setting saved / deleted (in admin) |

---

Expand Down
11 changes: 7 additions & 4 deletions shop/docs/STOREFRONT_IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,13 @@ A storefront feature is "done" when it has: read/write Eloquent models for the s
- [x] `A Iranian Sans` font + brand color `#ff8615`
- [x] Base `AppLayout` + sample `Home` page
- [x] Eloquent models mapping shared tables (read-focused): `Category`, `Brand`, `Product`, `Variety`, `Attribute`, `Image`, `Banner`, `Slider`/`Slide`, `Menu`/`MenuItem`, `Page`, `Faq`, `Review` (+ status enums and `HasOptions` trait). Relations to not-yet-created models (`AttributeGroup`, `Coupon`, `Discount`) are deferred to their phases.
- [ ] Shared UI kit components: `BaseButton`, `PriceTag`, `ProductCard`, `QuantityInput`, `Breadcrumbs`, `Pagination`, `RatingStars`, `EmptyState`
- [ ] Helpers/composables: Persian digits, Jalali date, money formatting (`useFormat`)
- [ ] SEO scaffolding: per-page `<Head>` pattern, shared meta defaults, canonical URL, Open Graph, `robots.txt`
- [ ] Error pages (404 / 500) in Persian, RTL
- [~] Shared UI kit components: `BaseButton` + `AppLink` + `Icon` done; `PriceTag`, `ProductCard`, `QuantityInput`, `Breadcrumbs`, `Pagination`, `RatingStars`, `EmptyState` pending
- [x] Helpers/composables: Persian digits, Jalali date, money formatting (`useFormat`)
- [x] SEO scaffolding: per-page `<Head>` via `AppHead` component, shared meta defaults (Inertia `seo` shared prop), canonical URL, Open Graph + Twitter, `robots.txt`
- [x] Error pages (404 / 500) in Persian, RTL (self-contained Blade, brand color, IranSans, no Vite/SSR dependency)
- [x] Shared database connection: shop reads the admin-owned Postgres (`shop_flow_db`); file/sync session/cache/queue so shop owns no tables (`.env.example` stays SQLite for CI/tests)
- [x] Icon system: FontAwesome (self-hosted via npm) behind the shared `Icon` component, icon-object pattern (SSR-safe). See `AGENTS.md`
- [x] Global footer wired from `settings`: read-only `Setting` model + Inertia shared `footer` (link columns, contact, socials, about, copyright) rendered by `AppFooter`/`Footer/*` components

## Phase 1 - Catalog browsing (highest SEO value, build first)

Expand Down
59 changes: 59 additions & 0 deletions shop/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions shop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
"vite": "^8.0.0"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.2.0",
"@fortawesome/free-brands-svg-icons": "^7.2.0",
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/vue-fontawesome": "^3.2.0",
"@inertiajs/vue3": "^3.4.0",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/server-renderer": "^3.5.38",
Expand Down
Loading
Loading