diff --git a/.github/workflows/deploy-application.yml b/.github/workflows/deploy-application.yml index cf4c3283..0b8a3fba 100644 --- a/.github/workflows/deploy-application.yml +++ b/.github/workflows/deploy-application.yml @@ -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 @@ -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 diff --git a/shop/.env.example b/shop/.env.example index c0660ea1..9403564d 100644 --- a/shop/.env.example +++ b/shop/.env.example @@ -1,4 +1,4 @@ -APP_NAME=Laravel +APP_NAME=ShopFlow APP_ENV=local APP_KEY= APP_DEBUG=true @@ -20,14 +20,18 @@ 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=/ @@ -35,9 +39,9 @@ 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 diff --git a/shop/AGENTS.md b/shop/AGENTS.md index dc6ce94f..31aa9a43 100644 --- a/shop/AGENTS.md +++ b/shop/AGENTS.md @@ -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 `` component (`resources/js/Components/Icon.vue`). Never use raw SVGs, emoji, or another icon library, and do not place `` 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: ``. + - 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 `` for SEO tags (see SEO section) and render meaningful content server-side. ## Language, RTL & fonts @@ -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 diff --git a/shop/app/Http/Middleware/HandleInertiaRequests.php b/shop/app/Http/Middleware/HandleInertiaRequests.php index b9701985..68f8e410 100644 --- a/shop/app/Http/Middleware/HandleInertiaRequests.php +++ b/shop/app/Http/Middleware/HandleInertiaRequests.php @@ -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 @@ -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 + */ + private function autoloadedSettings(): Collection + { + /** @var Collection $settings */ + $settings = rescue( + fn (): Collection => Setting::query()->autoloaded()->pluck('content', 'key'), + collect(), + false, + ); + + return $settings; + } + + /** + * @param Collection $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 $settings + * @return array + */ + 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 : []; + } } diff --git a/shop/app/Models/Setting.php b/shop/app/Models/Setting.php new file mode 100644 index 00000000..bd0ebdb0 --- /dev/null +++ b/shop/app/Models/Setting.php @@ -0,0 +1,37 @@ + 'boolean', + ]; + + public function scopeAutoloaded(Builder $query): Builder + { + return $query->where('autoload', true); + } +} diff --git a/shop/bootstrap/app.php b/shop/bootstrap/app.php index 4426bead..13effe0c 100644 --- a/shop/bootstrap/app.php +++ b/shop/bootstrap/app.php @@ -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( diff --git a/shop/docs/CACHE.md b/shop/docs/CACHE.md index e6d18529..7bec45cb 100644 --- a/shop/docs/CACHE.md +++ b/shop/docs/CACHE.md @@ -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) | --- diff --git a/shop/docs/STOREFRONT_IMPLEMENTATION.md b/shop/docs/STOREFRONT_IMPLEMENTATION.md index 5915a4bb..3e6f3f61 100644 --- a/shop/docs/STOREFRONT_IMPLEMENTATION.md +++ b/shop/docs/STOREFRONT_IMPLEMENTATION.md @@ -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 `` 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 `` 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) diff --git a/shop/package-lock.json b/shop/package-lock.json index 1279b516..0f53d400 100644 --- a/shop/package-lock.json +++ b/shop/package-lock.json @@ -5,6 +5,10 @@ "packages": { "": { "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", @@ -95,6 +99,61 @@ "tslib": "^2.4.0" } }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-7.2.0.tgz", + "integrity": "sha512-IpR0bER9FY25p+e7BmFH25MZKEwFHTfRAfhOyJubgiDnoJNsSvJ7nigLraHtp4VOG/cy8D7uiV0dLkHOne5Fhw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-7.2.0.tgz", + "integrity": "sha512-6639htZMjEkwskf3J+e6/iar+4cTNM9qhoWuRfj9F3eJD6r7iCzV1SWnQr2Mdv0QT0suuqU8BoJCZUyCtP9R4Q==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-brands-svg-icons": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-7.2.0.tgz", + "integrity": "sha512-VNG8xqOip1JuJcC3zsVsKRQ60oXG9+oYNDCosjoU/H9pgYmLTEwWw8pE0jhPz/JWdHeUuK6+NQ3qsM4gIbdbYQ==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-7.2.0.tgz", + "integrity": "sha512-YTVITFGN0/24PxzXrwqCgnyd7njDuzp5ZvaCx5nq/jg55kUYd94Nj8UTchBdBofi/L0nwRfjGOg0E41d2u9T1w==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "7.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/vue-fontawesome": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/vue-fontawesome/-/vue-fontawesome-3.2.0.tgz", + "integrity": "sha512-7BwGjTZn8QDvVEIu8fvkHhsDRRv//tq7jtsldaDhF3dE1fyWLIQcEg3zvIzy33su7kcppWsZZ6XRYP5wp3UCgQ==", + "license": "MIT", + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6 || ~7", + "vue": ">= 3.0.0 < 4" + } + }, "node_modules/@inertiajs/core": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/@inertiajs/core/-/core-3.4.0.tgz", diff --git a/shop/package.json b/shop/package.json index 2d613f2b..b6dc2b5c 100644 --- a/shop/package.json +++ b/shop/package.json @@ -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", diff --git a/shop/phpunit.xml b/shop/phpunit.xml index e7f0a48d..872fdabf 100644 --- a/shop/phpunit.xml +++ b/shop/phpunit.xml @@ -23,9 +23,16 @@ - - - + + + diff --git a/shop/public/robots.txt b/shop/public/robots.txt index eb053628..b098cdc7 100644 --- a/shop/public/robots.txt +++ b/shop/public/robots.txt @@ -1,2 +1,4 @@ User-agent: * Disallow: + +Sitemap: /sitemap.xml diff --git a/shop/resources/js/Components/AppHead.vue b/shop/resources/js/Components/AppHead.vue new file mode 100644 index 00000000..3396fb18 --- /dev/null +++ b/shop/resources/js/Components/AppHead.vue @@ -0,0 +1,106 @@ + + + diff --git a/shop/resources/js/Components/Footer/AppFooter.vue b/shop/resources/js/Components/Footer/AppFooter.vue index df6733c8..f9510cca 100644 --- a/shop/resources/js/Components/Footer/AppFooter.vue +++ b/shop/resources/js/Components/Footer/AppFooter.vue @@ -6,6 +6,10 @@ import FooterNewsletter from '@/Components/Footer/FooterNewsletter.vue'; import FooterCopyright from '@/Components/Footer/FooterCopyright.vue'; defineProps({ + about: { + type: String, + default: '', + }, columns: { type: Array, default: () => [], @@ -34,6 +38,13 @@ const emit = defineEmits(['subscribe']);